@startup-api/cloudflare 0.3.1 → 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
@@ -12,36 +12,35 @@ This application uses the Cloudflare Developer Platform, including Workers and D
12
12
 
13
13
  ## Installation
14
14
 
15
- ### Option 1: Cloudflare Workers GitHub Integration (Recommended)
15
+ Start a new project with the **`npm create startup-api`** scaffolder. It generates a tiny Cloudflare Worker that pulls this framework in as the [`@startup-api/cloudflare`](https://www.npmjs.com/package/@startup-api/cloudflare) npm dependency — so you stay up to date with `npm update` instead of maintaining a fork of this repository.
16
16
 
17
- This is the easiest way to deploy and keep your worker up to date.
17
+ ```bash
18
+ npm create startup-api my-app -- --origin https://your-app-origin.com
19
+ cd my-app
20
+ npm run dev # local dev at http://localhost:8787
21
+ npm run deploy # deploy to Cloudflare
22
+ ```
18
23
 
19
- 1. **Fork this repository** to your account
20
- 2. Go to your [Cloudflare Dashboard's Workers & pages > Create Application](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create)
21
- 3. Click **Continue with GitHub**
22
- 4. Select your forked `startup-api-cloudflare` repository
23
- 5. Pick the name for your site's worker (e.g. you might have multiple)
24
- 6. Deploy the Worker
25
- 7. In the **Settings** tab of your Worker, go to **Variables** and add the required `ORIGIN_URL` (see [Configuration](#configuration-details) below)
24
+ Run `npm create startup-api` with no arguments to be prompted for the project name and origin URL interactively. Useful flags: `--no-install` (skip `npm install`) and `--yes` / `-y` (non-interactive — requires a `name` and `--origin`).
26
25
 
27
- ### Option 2: Manual Installation (CLI)
26
+ What you get:
28
27
 
29
- Use this option if you want to deploy from your local machine.
28
+ - A minimal `src/index.ts` that re-exports the worker plus a `wrangler.jsonc` you control. The framework ships as the `@startup-api/cloudflare` dependency, so your project stays small.
29
+ - A `.dev.vars` file with a random `SESSION_SECRET` for local development. For production, set your own with `npx wrangler secret put SESSION_SECRET`.
30
+ - Framework updates are just `npm update @startup-api/cloudflare` — no fork to rebase.
30
31
 
31
- 1. **Clone and Install**
32
- ```bash
33
- git clone https://github.com/StartupAPI/startup-api-cloudflare.git
34
- cd startup-api-cloudflare
35
- npm install
36
- ```
37
- 2. **Configure Environment Variables**
32
+ Then set the required `ORIGIN_URL` and any OAuth credentials (see [Configuration](#configuration-details) below) and run `npm run deploy`. See [create-startup-api](https://github.com/StartupAPI/create-startup-api) for full details.
38
33
 
39
- Update `wrangler.jsonc` or use dashboard **Settings** tab of your Worker, go to **Variables** and add the required `ORIGIN_URL` (see [Configuration](#configuration-details) below)
34
+ ### Automated deployments
40
35
 
41
- 3. **Deploy**
42
- ```bash
43
- npm run deploy
44
- ```
36
+ `npm run deploy` deploys from your machine. To deploy automatically instead, push your scaffolded project to a GitHub repository and use either:
37
+
38
+ - **Cloudflare Workers GitHub app** — connect the repo to Cloudflare's [Workers Builds](https://developers.cloudflare.com/workers/ci-cd/builds/) Git integration and Cloudflare builds and deploys on every push, no CI config to maintain.
39
+ - **A GitHub Actions workflow** — run [`cloudflare/wrangler-action`](https://github.com/cloudflare/wrangler-action) on push to deploy with Wrangler. Add a `CLOUDFLARE_API_TOKEN` (and `CLOUDFLARE_ACCOUNT_ID`) repository secret so the action can authenticate.
40
+
41
+ Either way, set your production secrets (`SESSION_SECRET`, OAuth credentials) on the Worker in the Cloudflare dashboard or with `npx wrangler secret put` rather than committing them.
42
+
43
+ > **Working on the framework itself?** See [CONTRIBUTING.md](./CONTRIBUTING.md) for cloning and running this repository locally.
45
44
 
46
45
  ## Configuration Details
47
46
 
@@ -58,18 +57,20 @@ Use this option if you want to deploy from your local machine.
58
57
  - **Using `wrangler.jsonc`:**
59
58
  Add the variables to the `"vars"` object in your configuration file. See [Cloudflare documentation](https://developers.cloudflare.com/workers/wrangler/configuration/#environment-variables) for more details.
60
59
 
61
- | Variable | Required | Default | Description |
62
- | :--------------------- | :------- | :-------- | :---------------------------------------------------------------------------- |
63
- | `ORIGIN_URL` | **Yes** | N/A | The base URL of your origin application (e.g., `https://your-app-origin.com`) |
64
- | `USERS_PATH` | No | `/users/` | The path used to serve internal assets like `power-strip.js` |
65
- | `AUTH_ORIGIN` | No | N/A | Optional base URL for OAuth redirects (overrides request origin) |
66
- | `GOOGLE_CLIENT_ID` | No | N/A | Google OAuth2 Client ID |
67
- | `GOOGLE_CLIENT_SECRET` | No | N/A | Google OAuth2 Client Secret |
68
- | `TWITCH_CLIENT_ID` | No | N/A | Twitch OAuth2 Client ID |
69
- | `TWITCH_CLIENT_SECRET` | No | N/A | Twitch OAuth2 Client Secret |
70
- | `PATREON_CLIENT_ID` | No | N/A | Patreon OAuth2 Client ID |
71
- | `PATREON_CLIENT_SECRET`| No | N/A | Patreon OAuth2 Client Secret |
72
- | `PATREON_WEBHOOK_SECRET`| No | N/A | Secret for verifying Patreon webhook signatures |
60
+ | Variable | Required | Default | Description |
61
+ | :----------------------- | :------- | :-------- | :---------------------------------------------------------------------------- |
62
+ | `ORIGIN_URL` | **Yes** | N/A | The base URL of your origin application (e.g., `https://your-app-origin.com`) |
63
+ | `USERS_PATH` | No | `/users/` | The path used to serve internal assets like `power-strip.js` |
64
+ | `AUTH_ORIGIN` | No | N/A | Optional base URL for OAuth redirects (overrides request origin) |
65
+ | `GOOGLE_CLIENT_ID` | No | N/A | Google OAuth2 Client ID |
66
+ | `GOOGLE_CLIENT_SECRET` | No | N/A | Google OAuth2 Client Secret |
67
+ | `TWITCH_CLIENT_ID` | No | N/A | Twitch OAuth2 Client ID |
68
+ | `TWITCH_CLIENT_SECRET` | No | N/A | Twitch OAuth2 Client Secret |
69
+ | `PATREON_CLIENT_ID` | No | N/A | Patreon OAuth2 Client ID |
70
+ | `PATREON_CLIENT_SECRET` | No | N/A | Patreon OAuth2 Client Secret |
71
+ | `PATREON_WEBHOOK_SECRET` | No | N/A | Secret for verifying Patreon webhook signatures |
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).
73
74
 
74
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)).
75
76
 
@@ -100,6 +101,46 @@ Use this option if you want to deploy from your local machine.
100
101
  3. Add your authorized redirect URI: `https://<your-worker-url>/users/auth/patreon/callback`
101
102
  4. Copy the **Client ID** and **Client Secret** and add them to your Worker's environment variables
102
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
+
103
144
  #### Requesting additional scopes
104
145
 
105
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:
@@ -144,7 +185,7 @@ By default the worker injects its own `<power-strip>` pinned to the top-right co
144
185
  ```
145
186
 
146
187
  - **`providers` is optional.** If you omit it, the worker fills in the active providers for you (e.g. `providers="google,twitch,patreon"`). Set it yourself to override which login buttons appear.
147
- - **Prefer an explicit closing tag.** `<power-strip></power-strip>` and `<power-strip/>` are both detected, but per the HTML spec `<power-strip/>` is *not* truly self-closing — the browser treats it as an open tag and nests the following content inside it. Use a closing tag (or place the element last in its container) to avoid surprises.
188
+ - **Prefer an explicit closing tag.** `<power-strip></power-strip>` and `<power-strip/>` are both detected, but per the HTML spec `<power-strip/>` is _not_ truly self-closing — the browser treats it as an open tag and nests the following content inside it. Use a closing tag (or place the element last in its container) to avoid surprises.
148
189
  - **Script-only opt-out.** Use `<power-strip hidden>` to load `power-strip.js` (and its JS API) without rendering a visible strip.
149
190
 
150
191
  ## Access policy & provider entitlements
@@ -243,7 +284,12 @@ const api = createStartupAPI({
243
284
  freshness: { ttl: true, cron: { schedule: '0 */6 * * *' }, webhook: true },
244
285
  },
245
286
  },
246
- accessPolicy: { rules: [/* ... */], default: { mode: 'public' } },
287
+ accessPolicy: {
288
+ rules: [
289
+ /* ... */
290
+ ],
291
+ default: { mode: 'public' },
292
+ },
247
293
  });
248
294
 
249
295
  export default api.default; // includes scheduled() because cron is enabled
@@ -254,7 +300,7 @@ export const { UserDO, AccountDO, SystemDO, CredentialDO } = api;
254
300
 
255
301
  ## Contributing
256
302
 
257
- Contributions are welcome! Please feel free to submit a Pull Request.
303
+ Contributions are welcome! See [CONTRIBUTING.md](./CONTRIBUTING.md) for how to clone, run, test, and submit changes to the framework.
258
304
 
259
305
  ## License
260
306
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startup-api/cloudflare",
3
- "version": "0.3.1",
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