@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 +84 -38
- package/package.json +1 -1
- package/public/users/power-strip.js +23 -0
- package/public/users/profile.html +4 -0
- package/src/auth/AtprotoProvider.ts +282 -0
- package/src/auth/OAuthProvider.ts +103 -3
- package/src/auth/atproto/crypto.ts +119 -0
- package/src/auth/atproto/identity.ts +178 -0
- package/src/auth/index.ts +195 -195
- package/src/auth/providers.ts +2 -0
- package/src/createStartupAPI.ts +4 -4
- package/src/handlers/admin.ts +3 -1
- package/src/handlers/ssr.ts +6 -2
- package/src/handlers/utils.ts +7 -1
- package/src/schemas/config.ts +6 -0
- package/worker-configuration.d.ts +1 -5
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
26
|
+
What you get:
|
|
28
27
|
|
|
29
|
-
|
|
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
|
-
|
|
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
|
-
|
|
34
|
+
### Automated deployments
|
|
40
35
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
62
|
-
|
|
|
63
|
-
| `ORIGIN_URL`
|
|
64
|
-
| `USERS_PATH`
|
|
65
|
-
| `AUTH_ORIGIN`
|
|
66
|
-
| `GOOGLE_CLIENT_ID`
|
|
67
|
-
| `GOOGLE_CLIENT_SECRET`
|
|
68
|
-
| `TWITCH_CLIENT_ID`
|
|
69
|
-
| `TWITCH_CLIENT_SECRET`
|
|
70
|
-
| `PATREON_CLIENT_ID`
|
|
71
|
-
| `PATREON_CLIENT_SECRET
|
|
72
|
-
| `PATREON_WEBHOOK_SECRET
|
|
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
|
|
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: {
|
|
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!
|
|
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
|
@@ -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, '&')
|
|
278
|
+
.replace(/</g, '<')
|
|
279
|
+
.replace(/>/g, '>')
|
|
280
|
+
.replace(/"/g, '"')
|
|
281
|
+
.replace(/'/g, ''');
|
|
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
|
-
|
|
64
|
-
|
|
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
|