@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 +42 -0
- 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
|
@@ -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
|
@@ -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
|
|
@@ -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
|
+
}
|