@startup-api/cloudflare 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -70,6 +70,8 @@ Either way, set your production secrets (`SESSION_SECRET`, OAuth credentials) on
70
70
  | `PATREON_CLIENT_SECRET` | No | N/A | Patreon OAuth2 Client Secret |
71
71
  | `PATREON_WEBHOOK_SECRET` | No | N/A | Secret for verifying Patreon webhook signatures |
72
72
 
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
+
73
75
  > 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)).
74
76
 
75
77
  ### Setting up OAuth
@@ -99,6 +101,46 @@ Either way, set your production secrets (`SESSION_SECRET`, OAuth credentials) on
99
101
  3. Add your authorized redirect URI: `https://<your-worker-url>/users/auth/patreon/callback`
100
102
  4. Copy the **Client ID** and **Client Secret** and add them to your Worker's environment variables
101
103
 
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
+
102
144
  #### Requesting additional scopes
103
145
 
104
146
  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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startup-api/cloudflare",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "license": "Apache-2.0",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -212,6 +212,8 @@ class PowerStrip extends HTMLElement {
212
212
  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>`;
213
213
  } else if (provider === 'patreon') {
214
214
  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>`;
215
217
  }
216
218
  return '';
217
219
  }
@@ -221,6 +223,7 @@ class PowerStrip extends HTMLElement {
221
223
  const googleLink = `${this.basePath}/auth/google?return_url=${returnUrl}`;
222
224
  const twitchLink = `${this.basePath}/auth/twitch?return_url=${returnUrl}`;
223
225
  const patreonLink = `${this.basePath}/auth/patreon?return_url=${returnUrl}`;
226
+ const atprotoLink = `${this.basePath}/auth/atproto?return_url=${returnUrl}`;
224
227
  const logoutLink = `${this.basePath}/logout?return_url=${returnUrl}`;
225
228
 
226
229
  const providersStr = this.getAttribute('providers') || '';
@@ -257,6 +260,15 @@ class PowerStrip extends HTMLElement {
257
260
  Continue with Patreon
258
261
  </a>`;
259
262
  }
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
+ }
260
272
 
261
273
  let content = '';
262
274
  let accountSwitcher = '';
@@ -585,6 +597,7 @@ class PowerStrip extends HTMLElement {
585
597
  .provider-badge.google { color: #3c4043; }
586
598
  .provider-badge.twitch { color: #9146FF; }
587
599
  .provider-badge.patreon { color: #FF424D; }
600
+ .provider-badge.atproto { color: #0085FF; }
588
601
 
589
602
  .user-info {
590
603
  display: flex;
@@ -773,6 +786,16 @@ class PowerStrip extends HTMLElement {
773
786
  border-color: #e63a44;
774
787
  }
775
788
 
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
+
776
799
  /* Account Switcher Styling */
777
800
  .account-list {
778
801
  display: flex;
@@ -433,6 +433,10 @@
433
433
  return `<svg viewBox="0 0 24 24" width="24" height="24" style="color: #FF424D;">
434
434
  <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"/>
435
435
  </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>`;
436
440
  }
437
441
  return '';
438
442
  }
@@ -0,0 +1,282 @@
1
+ import type { StartupAPIEnv } from '../StartupAPIEnv';
2
+ import type { ProviderOptions } from '../schemas/config';
3
+
4
+ import { OAuthProvider, type AuthContext, type ExchangeResult, type OAuthTokenResponse, type UserProfile } from './OAuthProvider';
5
+ import { dpopFetch, generateDpopKey, generatePkce, randomToken } from './atproto/crypto';
6
+ import { fetchProfile, resolveIdentity, type ResolverOptions } from './atproto/identity';
7
+
8
+ const FLOW_COOKIE = 'atproto_flow';
9
+ // Transient flow state lives only for the duration of the redirect round-trip.
10
+ const FLOW_TTL_SECONDS = 600;
11
+
12
+ /**
13
+ * Encrypted, cookie-stored state that must survive the redirect from the authorization server back to
14
+ * our callback: the PKCE verifier, the DPoP private key, the latest DPoP nonce, and the dynamically
15
+ * discovered endpoints/identity for this specific user.
16
+ */
17
+ interface AtprotoFlowState {
18
+ state: string;
19
+ verifier: string;
20
+ dpopKey: JsonWebKey;
21
+ dpopNonce?: string;
22
+ issuer: string;
23
+ tokenEndpoint: string;
24
+ pds: string;
25
+ did: string;
26
+ handle?: string;
27
+ returnUrl: string | null;
28
+ }
29
+
30
+ /**
31
+ * Whether the atproto provider is turned on. It has no client secret (public OAuth client), so — like
32
+ * the env-credential providers are enabled by the presence of their credentials — atproto is enabled
33
+ * simply by including its config key (`providers: { atproto: {} }`). Pass `enabled: false` to opt out
34
+ * explicitly (e.g. when the config is built dynamically).
35
+ */
36
+ export function isAtprotoEnabled(options?: ProviderOptions): boolean {
37
+ return options !== undefined && options.enabled !== false;
38
+ }
39
+
40
+ /**
41
+ * AT Protocol (Bluesky and any atproto PDS) authentication.
42
+ *
43
+ * Unlike the classic OAuth2 providers, atproto requires PKCE, DPoP-bound tokens, Pushed Authorization
44
+ * Requests (PAR), and per-user dynamic endpoints discovered from the identity (handle → DID → PDS →
45
+ * authorization server). It is a "public" OAuth client identified by a hosted client-metadata document
46
+ * (served at `…/auth/atproto/client-metadata.json`) rather than a client id/secret.
47
+ */
48
+ export class AtprotoProvider extends OAuthProvider {
49
+ /** The public client id, i.e. the URL of this client's metadata document. */
50
+ private clientMetadataUrl = '';
51
+ private clientUri = '';
52
+ private clientName = 'StartupAPI';
53
+ private resolverOptions: ResolverOptions = {};
54
+
55
+ static create(_env: StartupAPIEnv, redirectBase: string, options?: ProviderOptions): AtprotoProvider | null {
56
+ if (!isAtprotoEnabled(options)) return null;
57
+ const provider = new AtprotoProvider('', '', redirectBase + '/atproto/callback', 'atproto', options?.scopes);
58
+ provider.clientMetadataUrl = redirectBase + '/atproto/client-metadata.json';
59
+ provider.clientUri = new URL(redirectBase).origin;
60
+ provider.clientName = options?.clientName?.trim() || 'StartupAPI';
61
+ provider.resolverOptions = {
62
+ plcUrl: options?.plcUrl?.trim() || undefined,
63
+ dohUrl: options?.dohUrl?.trim() || undefined,
64
+ };
65
+ return provider;
66
+ }
67
+
68
+ getIcon(): string {
69
+ // AT Protocol / Bluesky butterfly mark.
70
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
71
+ <circle cx="12" cy="12" r="11" fill="#0085FF" stroke="white" stroke-width="1"/>
72
+ <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"/>
73
+ </svg>`;
74
+ }
75
+
76
+ /** The OAuth client metadata document (public client, DPoP-bound tokens). */
77
+ getClientMetadata(): Record<string, unknown> {
78
+ return {
79
+ client_id: this.clientMetadataUrl,
80
+ client_name: this.clientName,
81
+ client_uri: this.clientUri,
82
+ redirect_uris: [this.redirectUri],
83
+ grant_types: ['authorization_code', 'refresh_token'],
84
+ response_types: ['code'],
85
+ scope: this.buildScope(['atproto']),
86
+ token_endpoint_auth_method: 'none',
87
+ application_type: 'web',
88
+ dpop_bound_access_tokens: true,
89
+ };
90
+ }
91
+
92
+ async handleExtraRoute(ctx: AuthContext): Promise<Response | null> {
93
+ if (ctx.url.pathname === `${ctx.authPath}/atproto/client-metadata.json`) {
94
+ return Response.json(this.getClientMetadata());
95
+ }
96
+ return null;
97
+ }
98
+
99
+ /**
100
+ * Authorization start. Requires an identifier (`?handle=`); without one we serve a small handle-entry
101
+ * form (the standard atproto UX) so we never hardcode any authorization server. With a handle we
102
+ * resolve the identity, run a DPoP-protected PAR, persist the flow state in an encrypted cookie, and
103
+ * redirect to the discovered authorization endpoint.
104
+ */
105
+ async authorize(ctx: AuthContext): Promise<Response> {
106
+ const identifier = ctx.url.searchParams.get('handle');
107
+ const returnUrl = ctx.url.searchParams.get('return_url');
108
+ if (!identifier || !identifier.trim()) {
109
+ return this.renderHandleForm(ctx, returnUrl);
110
+ }
111
+
112
+ const identity = await resolveIdentity(identifier, this.resolverOptions);
113
+ const { verifier, challenge } = await generatePkce();
114
+ const dpopKey = await generateDpopKey();
115
+ const state = randomToken(16);
116
+ const scope = this.buildScope(['atproto']);
117
+
118
+ // Pushed Authorization Request: hand the request parameters to the authorization server up front
119
+ // and receive an opaque request_uri to send the user to. DPoP is required.
120
+ const parBody = new URLSearchParams({
121
+ client_id: this.clientMetadataUrl,
122
+ redirect_uri: this.redirectUri,
123
+ response_type: 'code',
124
+ code_challenge: challenge,
125
+ code_challenge_method: 'S256',
126
+ state,
127
+ scope,
128
+ login_hint: identity.handle ?? identity.did,
129
+ });
130
+
131
+ const { res, nonce } = await dpopFetch(
132
+ identity.authServer.pushed_authorization_request_endpoint,
133
+ { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: parBody.toString() },
134
+ dpopKey,
135
+ );
136
+ if (!res.ok) {
137
+ throw new Error(`Pushed authorization request failed: ${res.status} ${await res.text()}`);
138
+ }
139
+ const par = (await res.json()) as { request_uri: string };
140
+
141
+ const flow: AtprotoFlowState = {
142
+ state,
143
+ verifier,
144
+ dpopKey,
145
+ dpopNonce: nonce,
146
+ issuer: identity.authServer.issuer,
147
+ tokenEndpoint: identity.authServer.token_endpoint,
148
+ pds: identity.pds,
149
+ did: identity.did,
150
+ handle: identity.handle,
151
+ returnUrl,
152
+ };
153
+ const encrypted = await ctx.cookieManager.encrypt(JSON.stringify(flow));
154
+
155
+ const authUrl = new URL(identity.authServer.authorization_endpoint);
156
+ authUrl.searchParams.set('client_id', this.clientMetadataUrl);
157
+ authUrl.searchParams.set('request_uri', par.request_uri);
158
+
159
+ const headers = new Headers();
160
+ headers.set('Location', authUrl.toString());
161
+ headers.append(
162
+ 'Set-Cookie',
163
+ `${FLOW_COOKIE}=${encrypted}; Path=${ctx.authPath}/atproto; HttpOnly; Secure; SameSite=Lax; Max-Age=${FLOW_TTL_SECONDS}`,
164
+ );
165
+ return new Response(null, { status: 302, headers });
166
+ }
167
+
168
+ /**
169
+ * Callback. Recover the encrypted flow state, validate `state`/`iss`, run the DPoP-protected token
170
+ * exchange against the discovered token endpoint, then resolve a profile from the user's PDS.
171
+ */
172
+ async exchange(ctx: AuthContext): Promise<ExchangeResult> {
173
+ const errorParam = ctx.url.searchParams.get('error');
174
+ if (errorParam) {
175
+ throw new Error(`atproto authorization error: ${errorParam} ${ctx.url.searchParams.get('error_description') ?? ''}`.trim());
176
+ }
177
+
178
+ const code = ctx.url.searchParams.get('code');
179
+ if (!code) {
180
+ const err = new Error('Missing code') as Error & { status?: number };
181
+ err.status = 400;
182
+ throw err;
183
+ }
184
+
185
+ const encrypted = readCookie(ctx.request.headers.get('Cookie'), FLOW_COOKIE);
186
+ if (!encrypted) throw new Error('Missing atproto flow state (cookie expired or blocked)');
187
+ const decrypted = await ctx.cookieManager.decrypt(encrypted);
188
+ const flow = decrypted ? (JSON.parse(decrypted) as AtprotoFlowState) : null;
189
+ if (!flow) throw new Error('Invalid atproto flow state');
190
+
191
+ if (flow.state !== ctx.url.searchParams.get('state')) throw new Error('State mismatch');
192
+ const iss = ctx.url.searchParams.get('iss');
193
+ if (iss && iss !== flow.issuer) throw new Error('Issuer mismatch');
194
+
195
+ const body = new URLSearchParams({
196
+ grant_type: 'authorization_code',
197
+ code,
198
+ redirect_uri: this.redirectUri,
199
+ client_id: this.clientMetadataUrl,
200
+ code_verifier: flow.verifier,
201
+ });
202
+
203
+ const { res } = await dpopFetch(
204
+ flow.tokenEndpoint,
205
+ { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body.toString() },
206
+ flow.dpopKey,
207
+ flow.dpopNonce,
208
+ );
209
+ if (!res.ok) {
210
+ throw new Error(`Token request failed: ${res.status} ${await res.text()}`);
211
+ }
212
+
213
+ const tokenData = (await res.json()) as OAuthTokenResponse & { sub?: string };
214
+ const did = tokenData.sub || flow.did;
215
+ const { name, picture } = await fetchProfile(flow.pds, did, flow.handle);
216
+
217
+ const profile: UserProfile = { id: did, name: name || flow.handle || did, picture };
218
+ const token: OAuthTokenResponse = {
219
+ access_token: tokenData.access_token,
220
+ refresh_token: tokenData.refresh_token,
221
+ expires_in: tokenData.expires_in,
222
+ scope: tokenData.scope,
223
+ token_type: tokenData.token_type,
224
+ };
225
+
226
+ const clearCookie = `${FLOW_COOKIE}=; Path=${ctx.authPath}/atproto; HttpOnly; Secure; SameSite=Lax; Max-Age=0`;
227
+ return { token, profile, returnUrl: flow.returnUrl ?? null, setCookies: [clearCookie] };
228
+ }
229
+
230
+ /** Minimal handle-entry page shown when the user starts the flow without an identifier. */
231
+ private renderHandleForm(ctx: AuthContext, returnUrl: string | null): Response {
232
+ const action = `${ctx.authPath}/atproto`;
233
+ const returnField = returnUrl ? `<input type="hidden" name="return_url" value="${escapeHtml(returnUrl)}" />` : '';
234
+ const html = `<!doctype html>
235
+ <html lang="en">
236
+ <head>
237
+ <meta charset="utf-8" />
238
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
239
+ <title>Sign in with Bluesky / atproto</title>
240
+ <style>
241
+ body { font-family: system-ui, sans-serif; background: #f5f7fb; margin: 0; display: flex; min-height: 100vh; align-items: center; justify-content: center; }
242
+ .card { background: #fff; padding: 2rem; border-radius: 12px; box-shadow: 0 8px 30px rgba(0,0,0,0.08); width: 320px; }
243
+ h1 { font-size: 1.15rem; margin: 0 0 1rem; }
244
+ label { display: block; font-size: 0.85rem; color: #444; margin-bottom: 0.35rem; }
245
+ input[type=text] { width: 100%; box-sizing: border-box; padding: 0.6rem 0.7rem; border: 1px solid #ccd2dd; border-radius: 8px; font-size: 0.95rem; }
246
+ button { margin-top: 1rem; width: 100%; padding: 0.65rem; border: 0; border-radius: 8px; background: #0085FF; color: #fff; font-size: 0.95rem; cursor: pointer; }
247
+ p { font-size: 0.8rem; color: #777; margin-top: 0.75rem; }
248
+ </style>
249
+ </head>
250
+ <body>
251
+ <form class="card" method="GET" action="${escapeHtml(action)}">
252
+ <h1>Sign in with Bluesky / atproto</h1>
253
+ <label for="handle">Your handle or DID</label>
254
+ <input type="text" id="handle" name="handle" placeholder="alice.bsky.social" autocomplete="username" autofocus required />
255
+ ${returnField}
256
+ <button type="submit">Continue</button>
257
+ <p>Enter your atproto handle (e.g. alice.bsky.social) or DID. Your account's own server handles the login.</p>
258
+ </form>
259
+ </body>
260
+ </html>`;
261
+ return new Response(html, { status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8' } });
262
+ }
263
+ }
264
+
265
+ /** Read a single cookie value from a Cookie header, or undefined. */
266
+ function readCookie(header: string | null, name: string): string | undefined {
267
+ if (!header) return undefined;
268
+ for (const part of header.split(';')) {
269
+ const [key, ...rest] = part.split('=');
270
+ if (key.trim() === name) return rest.join('=').trim();
271
+ }
272
+ return undefined;
273
+ }
274
+
275
+ function escapeHtml(value: string): string {
276
+ return value
277
+ .replace(/&/g, '&amp;')
278
+ .replace(/</g, '&lt;')
279
+ .replace(/>/g, '&gt;')
280
+ .replace(/"/g, '&quot;')
281
+ .replace(/'/g, '&#39;');
282
+ }
@@ -16,6 +16,50 @@ export interface UserProfile {
16
16
  }
17
17
 
18
18
  import type { Entitlements } from '../entitlements/types';
19
+ import type { StartupAPIEnv } from '../StartupAPIEnv';
20
+ import type { CookieManager } from '../CookieManager';
21
+
22
+ /**
23
+ * Per-request context handed to a provider's flow hooks. Carries everything needed to run an
24
+ * authorization start or callback without the provider reaching back into the router.
25
+ */
26
+ export interface AuthContext {
27
+ request: Request;
28
+ env: StartupAPIEnv;
29
+ url: URL;
30
+ /** Base URL provider redirect/callback URIs are built from, e.g. `https://host/users/auth`. */
31
+ redirectBase: string;
32
+ /** Pathname of `redirectBase`, e.g. `/users/auth`. */
33
+ authPath: string;
34
+ /** Configured users path, e.g. `/users/`. */
35
+ usersPath: string;
36
+ /** Effective origin (AUTH_ORIGIN override or request origin). */
37
+ origin: string;
38
+ cookieManager: CookieManager;
39
+ }
40
+
41
+ /**
42
+ * Result of a successful callback exchange: the token, the resolved user profile, where to send the
43
+ * user next, and any extra cookies to emit (e.g. clearing transient flow state).
44
+ */
45
+ export interface ExchangeResult {
46
+ token: OAuthTokenResponse;
47
+ profile: UserProfile;
48
+ returnUrl: string | null;
49
+ setCookies?: string[];
50
+ }
51
+
52
+ /** Decode the base64url state param used by the standard flow back into its return_url. */
53
+ function parseReturnUrl(stateBase64: string | null): string | null {
54
+ if (!stateBase64) return null;
55
+ try {
56
+ const base64 = stateBase64.replace(/-/g, '+').replace(/_/g, '/');
57
+ const stateJson = decodeURIComponent(escape(atob(base64)));
58
+ return JSON.parse(stateJson).return_url ?? null;
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
19
63
 
20
64
  export abstract class OAuthProvider {
21
65
  protected clientId: string;
@@ -58,10 +102,66 @@ export abstract class OAuthProvider {
58
102
  return path === `${authBasePath}/${this.name}/callback`;
59
103
  }
60
104
 
61
- abstract getAuthUrl(state: string): string;
62
105
  abstract getIcon(): string;
63
- abstract getToken(code: string): Promise<OAuthTokenResponse>;
64
- abstract getUserProfile(token: string): Promise<UserProfile>;
106
+
107
+ /**
108
+ * Simple OAuth2 hooks used by the default {@link authorize}/{@link exchange}. Providers whose flow
109
+ * fits the classic "redirect → code → token → profile" shape implement these. Providers with a
110
+ * heavier flow (e.g. atproto's PKCE/DPoP/PAR) instead override {@link authorize}/{@link exchange}
111
+ * and may leave these as the throwing defaults.
112
+ */
113
+ getAuthUrl(_state: string): string {
114
+ throw new Error(`${this.name}: getAuthUrl is not implemented`);
115
+ }
116
+
117
+ async getToken(_code: string): Promise<OAuthTokenResponse> {
118
+ throw new Error(`${this.name}: getToken is not implemented`);
119
+ }
120
+
121
+ async getUserProfile(_token: string): Promise<UserProfile> {
122
+ throw new Error(`${this.name}: getUserProfile is not implemented`);
123
+ }
124
+
125
+ /**
126
+ * Begin the authorization flow. Default: build a base64url `state` (nonce + return_url) and redirect
127
+ * to {@link getAuthUrl}. Providers needing async setup, server-side flow state, or custom request
128
+ * shapes override this and return their own Response.
129
+ */
130
+ async authorize(ctx: AuthContext): Promise<Response> {
131
+ const returnUrl = ctx.url.searchParams.get('return_url');
132
+ const stateObj = { nonce: Math.random().toString(36).substring(2), return_url: returnUrl };
133
+ const state = btoa(unescape(encodeURIComponent(JSON.stringify(stateObj))))
134
+ .replace(/\+/g, '-')
135
+ .replace(/\//g, '_')
136
+ .replace(/=+$/, '');
137
+ return Response.redirect(this.getAuthUrl(state), 302);
138
+ }
139
+
140
+ /**
141
+ * Exchange a callback for a token and resolved profile. Default: read `code`, recover the return_url
142
+ * from `state`, then {@link getToken} + {@link getUserProfile}. Providers override this for custom
143
+ * token exchanges (PKCE/DPoP, dynamic endpoints, etc.).
144
+ */
145
+ async exchange(ctx: AuthContext): Promise<ExchangeResult> {
146
+ const code = ctx.url.searchParams.get('code');
147
+ if (!code) {
148
+ const err = new Error('Missing code') as Error & { status?: number };
149
+ err.status = 400;
150
+ throw err;
151
+ }
152
+ const returnUrl = parseReturnUrl(ctx.url.searchParams.get('state'));
153
+ const token = await this.getToken(code);
154
+ const profile = await this.getUserProfile(token.access_token);
155
+ return { token, profile, returnUrl };
156
+ }
157
+
158
+ /**
159
+ * Serve any provider-specific auxiliary GET routes mounted under the auth base path (e.g. the atproto
160
+ * client-metadata document). Default: not a provider route → null, so the router moves on.
161
+ */
162
+ async handleExtraRoute(_ctx: AuthContext): Promise<Response | null> {
163
+ return null;
164
+ }
65
165
 
66
166
  /**
67
167
  * Whether this provider produces entitlements (memberships / perks). Providers that gate access on
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Cryptographic primitives for the AT Protocol OAuth flow: base64url helpers, PKCE, and DPoP
3
+ * (RFC 9449) proof generation. atproto OAuth requires PKCE and sender-constrained (DPoP-bound)
4
+ * tokens, so every request to the authorization/token endpoints carries a freshly signed ES256
5
+ * proof-of-possession JWT. All of this runs on the WebCrypto API available in Workers.
6
+ */
7
+
8
+ /** Encode raw bytes as unpadded base64url. */
9
+ export function base64urlEncode(input: ArrayBuffer | Uint8Array): string {
10
+ const bytes = input instanceof Uint8Array ? input : new Uint8Array(input);
11
+ let str = '';
12
+ for (const b of bytes) str += String.fromCharCode(b);
13
+ return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
14
+ }
15
+
16
+ /** Encode a UTF-8 string as unpadded base64url. */
17
+ export function base64urlEncodeString(value: string): string {
18
+ return base64urlEncode(new TextEncoder().encode(value));
19
+ }
20
+
21
+ /** A random unpadded-base64url token of `byteLength` random bytes (used for state, jti, PKCE verifier). */
22
+ export function randomToken(byteLength = 32): string {
23
+ return base64urlEncode(crypto.getRandomValues(new Uint8Array(byteLength)));
24
+ }
25
+
26
+ export interface Pkce {
27
+ verifier: string;
28
+ challenge: string;
29
+ }
30
+
31
+ /** Generate a PKCE verifier and its S256 challenge. */
32
+ export async function generatePkce(): Promise<Pkce> {
33
+ const verifier = randomToken(32);
34
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
35
+ return { verifier, challenge: base64urlEncode(digest) };
36
+ }
37
+
38
+ const ES256_PARAMS = { name: 'ECDSA', namedCurve: 'P-256' } as const;
39
+
40
+ /**
41
+ * Generate an ephemeral ES256 (P-256) keypair for DPoP and return its private JWK. The private JWK is
42
+ * persisted in the (encrypted) flow state so the same key can be reused for the token request and any
43
+ * later refresh; the public half is embedded in each proof's header.
44
+ */
45
+ export async function generateDpopKey(): Promise<JsonWebKey> {
46
+ const pair = (await crypto.subtle.generateKey(ES256_PARAMS, true, ['sign', 'verify'])) as CryptoKeyPair;
47
+ return (await crypto.subtle.exportKey('jwk', pair.privateKey)) as JsonWebKey;
48
+ }
49
+
50
+ /** The public portion of a DPoP JWK, as embedded in the proof header. */
51
+ function publicJwk(jwk: JsonWebKey): JsonWebKey {
52
+ return { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y };
53
+ }
54
+
55
+ /**
56
+ * Build a DPoP proof JWT bound to the given HTTP method and URL (htu is normalized to origin+path, per
57
+ * RFC 9449). When the server has issued a nonce it must be echoed back in the proof.
58
+ */
59
+ export async function createDpopProof(privateJwk: JsonWebKey, htm: string, htu: string, nonce?: string): Promise<string> {
60
+ const key = await crypto.subtle.importKey('jwk', privateJwk, ES256_PARAMS, false, ['sign']);
61
+ const target = new URL(htu);
62
+ const header = { typ: 'dpop+jwt', alg: 'ES256', jwk: publicJwk(privateJwk) };
63
+ const payload: Record<string, unknown> = {
64
+ jti: randomToken(16),
65
+ htm: htm.toUpperCase(),
66
+ htu: target.origin + target.pathname,
67
+ iat: Math.floor(Date.now() / 1000),
68
+ };
69
+ if (nonce) payload.nonce = nonce;
70
+
71
+ const signingInput = `${base64urlEncodeString(JSON.stringify(header))}.${base64urlEncodeString(JSON.stringify(payload))}`;
72
+ const signature = await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, key, new TextEncoder().encode(signingInput));
73
+ // WebCrypto ECDSA already returns the raw r||s signature that JOSE/ES256 expects.
74
+ return `${signingInput}.${base64urlEncode(signature)}`;
75
+ }
76
+
77
+ export interface DpopResult {
78
+ res: Response;
79
+ /** The most recent server-issued DPoP nonce, to be reused on the next request. */
80
+ nonce?: string;
81
+ }
82
+
83
+ /**
84
+ * POST to a DPoP-protected endpoint, transparently handling the `use_dpop_nonce` challenge: the first
85
+ * request is sent without (or with the cached) nonce; if the server demands a fresh nonce we retry once
86
+ * with it. Returns the final response and the latest nonce so the caller can chain requests (PAR →
87
+ * token) without re-fetching one.
88
+ */
89
+ export async function dpopFetch(url: string, init: RequestInit, privateJwk: JsonWebKey, nonce?: string): Promise<DpopResult> {
90
+ let currentNonce = nonce;
91
+ const method = (init.method || 'POST').toUpperCase();
92
+
93
+ for (let attempt = 0; attempt < 2; attempt++) {
94
+ const proof = await createDpopProof(privateJwk, method, url, currentNonce);
95
+ const headers = new Headers(init.headers);
96
+ headers.set('DPoP', proof);
97
+ const res = await fetch(url, { ...init, headers });
98
+ const serverNonce = res.headers.get('DPoP-Nonce') ?? undefined;
99
+
100
+ if ((res.status === 400 || res.status === 401) && serverNonce && attempt === 0) {
101
+ let needsNonce = false;
102
+ try {
103
+ const body = (await res.clone().json()) as { error?: string };
104
+ needsNonce = body?.error === 'use_dpop_nonce';
105
+ } catch {
106
+ // Non-JSON body; fall through and surface the original response.
107
+ }
108
+ if (needsNonce) {
109
+ currentNonce = serverNonce;
110
+ continue;
111
+ }
112
+ }
113
+
114
+ return { res, nonce: serverNonce ?? currentNonce };
115
+ }
116
+
117
+ // Unreachable: the loop either returns a response or retries exactly once then returns.
118
+ throw new Error('dpopFetch: exhausted retries');
119
+ }