Skip to content

Commit 857bcf0

Browse files
Add AT Protocol (Bluesky) authentication provider (#102)
* Add AT Protocol (Bluesky) authentication provider Implements a spec-compliant atproto OAuth login provider alongside the existing Google/Twitch/Patreon providers. atproto's flow is heavier than classic OAuth2, so this also extends the provider abstraction to support async, multi-step flows without changing existing providers' behavior. Highlights: - Full identity discovery with no hardcoded Bluesky/PDS hosts: handle -> DID (HTTPS .well-known/atproto-did, then DNS _atproto.<handle> over DoH) -> DID document (did:plc via the PLC directory, did:web via the domain) -> PDS (#atproto_pds service) -> authorization server (.well-known/oauth-protected-resource -> oauth-authorization-server). - PKCE, DPoP-bound tokens (RFC 9449, with use_dpop_nonce retry), and Pushed Authorization Requests (PAR), as required by the atproto OAuth profile. - Public OAuth client: serves its own client-metadata.json (the client_id), no client secret. Enabled via ATPROTO_ENABLED; PLC/DoH endpoints overridable via ATPROTO_PLC_URL / ATPROTO_DOH_URL. - Transient PKCE verifier + DPoP key + discovered endpoints are persisted in an encrypted, short-lived flow cookie across the redirect round-trip. - Handle-entry form shown when no identifier is supplied (standard atproto UX). - OAuthProvider gains optional authorize()/exchange()/handleExtraRoute() hooks; shared user/session creation extracted into finishLogin(). - Power-strip + profile UI: "Continue with Bluesky" button and provider icons. - Tests covering metadata, handle form, PAR+DPoP+PKCE (with nonce retry), callback token exchange/session/credential, and state-mismatch rejection. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015SgFKgH1gqfP2Zx3yrnzY2 * Configure atproto via factory config object instead of env vars atproto is a public OAuth client with no secrets, so per the project's configuration philosophy (env holds only secrets/per-deployment values) all of its settings now live in the createStartupAPI factory config rather than env: - enabled, clientName, plcUrl, dohUrl moved onto ProviderOptions (alongside the existing Patreon-specific campaignId); enablement is providers.atproto.enabled. - isAtprotoEnabled() now takes ProviderOptions; AtprotoProvider.create reads clientName/plcUrl/dohUrl from options. - getActiveProviders(env, providerConfigs) is config-aware; providerConfigs is threaded through handleSSR and handleAdmin so the power-strip/profile/admin UIs list atproto when enabled. - Removed ATPROTO_* fields from StartupAPIEnv and wrangler.jsonc; regenerated worker-configuration.d.ts. - README + tests updated to enable atproto via the config object. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015SgFKgH1gqfP2Zx3yrnzY2 * Enable atproto by presence of its config key, not enabled: true atproto has no env credentials, so the natural equivalent of "enabled because credentials are present" is "enabled because the config key is present". `providers: { atproto: {} }` now enables it; `enabled: false` remains as an explicit opt-out for dynamically-built config. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015SgFKgH1gqfP2Zx3yrnzY2 * Bump version to 0.4.0 Minor release for the new AT Protocol (Bluesky) authentication provider. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4102412 commit 857bcf0

18 files changed

Lines changed: 1211 additions & 214 deletions

README.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ Either way, set your production secrets (`SESSION_SECRET`, OAuth credentials) on
7070
| `PATREON_CLIENT_SECRET` | No | N/A | Patreon OAuth2 Client Secret |
7171
| `PATREON_WEBHOOK_SECRET` | No | N/A | Secret for verifying Patreon webhook signatures |
7272

73+
> AT Protocol (Bluesky) login needs **no environment variables at all** — it is a public OAuth client with no secret, so it is configured entirely through the `createStartupAPI` factory (see [Bluesky / AT Protocol](#bluesky--at-protocol-atproto) below).
74+
7375
> Environment variables hold only credentials/secrets (OAuth client IDs and all secrets) plus the per‑deployment values `ORIGIN_URL`, `AUTH_ORIGIN`, `USERS_PATH`, `ADMIN_IDS`, and `ENVIRONMENT`. **All other configuration — OAuth scopes, Patreon campaign id, the access policy, entitlement freshness — is passed to the `createStartupAPI` factory** (see [Access policy & provider entitlements](#access-policy--provider-entitlements)).
7476
7577
### Setting up OAuth
@@ -99,6 +101,46 @@ Either way, set your production secrets (`SESSION_SECRET`, OAuth credentials) on
99101
3. Add your authorized redirect URI: `https://<your-worker-url>/users/auth/patreon/callback`
100102
4. Copy the **Client ID** and **Client Secret** and add them to your Worker's environment variables
101103

104+
#### Bluesky / AT Protocol (atproto)
105+
106+
atproto login is decentralized: there is **no central provider to register with and no client secret**. Instead the worker acts as a [public OAuth client](https://atproto.com/specs/oauth) identified by a client-metadata document it serves itself, and it discovers the right authorization server **per user** from their handle or DID — so it works with `bsky.social` and any self-hosted PDS alike, with no Bluesky host hardcoded.
107+
108+
Because it has no secrets, atproto is configured **entirely through the `createStartupAPI` factory** (not env vars). Just like the env-credential providers are enabled by the presence of their credentials, atproto is enabled simply by **including its config key** — an empty object is enough:
109+
110+
```ts
111+
import { createStartupAPI } from '@startup-api/cloudflare';
112+
113+
const api = createStartupAPI({
114+
providers: {
115+
atproto: {}, // including the key enables it — no client id/secret needed
116+
// All fields below are optional:
117+
// atproto: {
118+
// clientName: 'My App', // shown on the consent screen (default "StartupAPI")
119+
// plcUrl: 'https://plc.directory', // override the PLC directory for did:plc
120+
// dohUrl: 'https://cloudflare-dns.com/dns-query', // override the DoH resolver
121+
// scopes: 'transition:generic', // extra scopes on top of the base `atproto`
122+
// enabled: false, // explicit opt-out (e.g. for dynamically-built config)
123+
// },
124+
},
125+
});
126+
127+
export default api.default;
128+
export const { UserDO, AccountDO, SystemDO, CredentialDO } = api;
129+
```
130+
131+
1. Include `atproto: {}` in the factory `providers` config (no client id/secret needed). Pass `enabled: false` to opt out explicitly.
132+
2. Deploy over **HTTPS** with a stable hostname. The worker automatically serves its client metadata at `https://<your-worker-url>/users/auth/atproto/client-metadata.json` (this URL is the OAuth `client_id`) and registers the redirect URI `https://<your-worker-url>/users/auth/atproto/callback`.
133+
3. That's it. When a visitor clicks **Continue with Bluesky**, they're asked for their handle (e.g. `alice.bsky.social`) or DID; the worker then resolves it through the full atproto discovery chain and redirects them to _their own_ server to sign in:
134+
135+
```
136+
handle ─▶ DID (HTTPS .well-known/atproto-did, then DNS _atproto.<handle> via DoH)
137+
DID ─▶ DID document (did:plc via the PLC directory, did:web via the domain)
138+
DID doc─▶ PDS endpoint (the #atproto_pds service)
139+
PDS ─▶ auth server (.well-known/oauth-protected-resource → oauth-authorization-server)
140+
```
141+
142+
The flow uses PKCE, DPoP-bound (sender-constrained) tokens, and Pushed Authorization Requests (PAR) as required by the atproto OAuth profile. The PLC directory and DNS-over-HTTPS resolver are generic infrastructure and can be overridden via the `plcUrl` / `dohUrl` factory options.
143+
102144
#### Requesting additional scopes
103145

104146
Each provider requests the minimal scopes needed to sign a user in and read their basic profile. To request more (for example, to read a user's Patreon memberships), set the provider's `scopes` in the factory config (a string or array). The extra scopes are merged with the required base scopes:

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@startup-api/cloudflare",
3-
"version": "0.3.2",
3+
"version": "0.4.0",
44
"license": "Apache-2.0",
55
"publishConfig": {
66
"access": "public"

public/users/power-strip.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,8 @@ class PowerStrip extends HTMLElement {
212212
return `<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="11" fill="#9146FF" stroke="white" stroke-width="1"/><path d="M7 6H6v10h2v3l3-3h3l4-4V6H7zm9 6l-2 2h-3l-2 2v-2H8V7h8v5z" fill="white"/><path d="M14 8.5h1.5v2H14V8.5zm-3 0h1.5v2H11v-2z" fill="white"/></svg>`;
213213
} else if (provider === 'patreon') {
214214
return `<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="11" fill="#FF424D" stroke="white" stroke-width="1"/><circle cx="14" cy="11" r="3.5" fill="white"/><rect x="6.5" y="6.5" width="2" height="11" fill="white"/></svg>`;
215+
} else if (provider === 'atproto') {
216+
return `<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="11" fill="#0085FF" stroke="white" stroke-width="1"/><path d="M12 10.5C10.9 8.4 8.2 6.3 6.3 6c-1.5-.2-1.8.7-1.5 2 .2 1 1.5 5 2.3 6 .9 1.2 2 1.4 3 1.2-1.7.3-3.2 1-1.2 3 .9.9 1.6.3 2.1-.6.5-1 .8-2.1 1-2.6.2.5.5 1.6 1 2.6.5.9 1.2 1.5 2.1.6 2-2 .5-2.7-1.2-3 1 .2 2.1 0 3-1.2.8-1 2.1-5 2.3-6 .3-1.3 0-2.2-1.5-2-1.9.3-4.6 2.4-5.7 4.5z" fill="white"/></svg>`;
215217
}
216218
return '';
217219
}
@@ -221,6 +223,7 @@ class PowerStrip extends HTMLElement {
221223
const googleLink = `${this.basePath}/auth/google?return_url=${returnUrl}`;
222224
const twitchLink = `${this.basePath}/auth/twitch?return_url=${returnUrl}`;
223225
const patreonLink = `${this.basePath}/auth/patreon?return_url=${returnUrl}`;
226+
const atprotoLink = `${this.basePath}/auth/atproto?return_url=${returnUrl}`;
224227
const logoutLink = `${this.basePath}/logout?return_url=${returnUrl}`;
225228

226229
const providersStr = this.getAttribute('providers') || '';
@@ -257,6 +260,15 @@ class PowerStrip extends HTMLElement {
257260
Continue with Patreon
258261
</a>`;
259262
}
263+
if (providers.includes('atproto')) {
264+
authButtons += `
265+
<a href="${atprotoLink}" class="auth-btn atproto">
266+
<svg viewBox="0 0 24 24">
267+
<path d="M12 10.5C10.9 8.4 8.2 6.3 6.3 6c-1.5-.2-1.8.7-1.5 2 .2 1 1.5 5 2.3 6 .9 1.2 2 1.4 3 1.2-1.7.3-3.2 1-1.2 3 .9.9 1.6.3 2.1-.6.5-1 .8-2.1 1-2.6.2.5.5 1.6 1 2.6.5.9 1.2 1.5 2.1.6 2-2 .5-2.7-1.2-3 1 .2 2.1 0 3-1.2.8-1 2.1-5 2.3-6 .3-1.3 0-2.2-1.5-2-1.9.3-4.6 2.4-5.7 4.5z" fill="currentColor"/>
268+
</svg>
269+
Continue with Bluesky
270+
</a>`;
271+
}
260272

261273
let content = '';
262274
let accountSwitcher = '';
@@ -585,6 +597,7 @@ class PowerStrip extends HTMLElement {
585597
.provider-badge.google { color: #3c4043; }
586598
.provider-badge.twitch { color: #9146FF; }
587599
.provider-badge.patreon { color: #FF424D; }
600+
.provider-badge.atproto { color: #0085FF; }
588601
589602
.user-info {
590603
display: flex;
@@ -773,6 +786,16 @@ class PowerStrip extends HTMLElement {
773786
border-color: #e63a44;
774787
}
775788
789+
.auth-btn.atproto {
790+
background-color: #0085FF;
791+
color: white;
792+
border-color: #0085FF;
793+
}
794+
.auth-btn.atproto:hover {
795+
background-color: #006fd6;
796+
border-color: #006fd6;
797+
}
798+
776799
/* Account Switcher Styling */
777800
.account-list {
778801
display: flex;

public/users/profile.html

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -433,6 +433,10 @@ <h3>Link another account</h3>
433433
return `<svg viewBox="0 0 24 24" width="24" height="24" style="color: #FF424D;">
434434
<path d="M14.82 2.41c3.96 0 7.18 3.24 7.18 7.21 0 3.96-3.22 7.18-7.18 7.18-3.97 0-7.21-3.22-7.21-7.18 0-3.97 3.24-7.21 7.21-7.21M2 21.6h3.5V2.41H2V21.6z" fill="currentColor"/>
435435
</svg>`;
436+
} else if (provider === 'atproto') {
437+
return `<svg viewBox="0 0 24 24" width="24" height="24" style="color: #0085FF;">
438+
<path d="M12 10.5C10.9 8.4 8.2 6.3 6.3 6c-1.5-.2-1.8.7-1.5 2 .2 1 1.5 5 2.3 6 .9 1.2 2 1.4 3 1.2-1.7.3-3.2 1-1.2 3 .9.9 1.6.3 2.1-.6.5-1 .8-2.1 1-2.6.2.5.5 1.6 1 2.6.5.9 1.2 1.5 2.1.6 2-2 .5-2.7-1.2-3 1 .2 2.1 0 3-1.2.8-1 2.1-5 2.3-6 .3-1.3 0-2.2-1.5-2-1.9.3-4.6 2.4-5.7 4.5z" fill="currentColor"/>
439+
</svg>`;
436440
}
437441
return '';
438442
}

0 commit comments

Comments
 (0)