diff --git a/README.md b/README.md index b7f30ab..46e082c 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,8 @@ Either way, set your production secrets (`SESSION_SECRET`, OAuth credentials) on | `PATREON_CLIENT_SECRET` | No | N/A | Patreon OAuth2 Client Secret | | `PATREON_WEBHOOK_SECRET` | No | N/A | Secret for verifying Patreon webhook signatures | +> 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). + > 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)). ### Setting up OAuth @@ -99,6 +101,46 @@ Either way, set your production secrets (`SESSION_SECRET`, OAuth credentials) on 3. Add your authorized redirect URI: `https:///users/auth/patreon/callback` 4. Copy the **Client ID** and **Client Secret** and add them to your Worker's environment variables +#### Bluesky / AT Protocol (atproto) + +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. + +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: + +```ts +import { createStartupAPI } from '@startup-api/cloudflare'; + +const api = createStartupAPI({ + providers: { + atproto: {}, // including the key enables it — no client id/secret needed + // All fields below are optional: + // atproto: { + // clientName: 'My App', // shown on the consent screen (default "StartupAPI") + // plcUrl: 'https://plc.directory', // override the PLC directory for did:plc + // dohUrl: 'https://cloudflare-dns.com/dns-query', // override the DoH resolver + // scopes: 'transition:generic', // extra scopes on top of the base `atproto` + // enabled: false, // explicit opt-out (e.g. for dynamically-built config) + // }, + }, +}); + +export default api.default; +export const { UserDO, AccountDO, SystemDO, CredentialDO } = api; +``` + +1. Include `atproto: {}` in the factory `providers` config (no client id/secret needed). Pass `enabled: false` to opt out explicitly. +2. Deploy over **HTTPS** with a stable hostname. The worker automatically serves its client metadata at `https:///users/auth/atproto/client-metadata.json` (this URL is the OAuth `client_id`) and registers the redirect URI `https:///users/auth/atproto/callback`. +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: + + ``` + handle ─▶ DID (HTTPS .well-known/atproto-did, then DNS _atproto. via DoH) + DID ─▶ DID document (did:plc via the PLC directory, did:web via the domain) + DID doc─▶ PDS endpoint (the #atproto_pds service) + PDS ─▶ auth server (.well-known/oauth-protected-resource → oauth-authorization-server) + ``` + +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. + #### Requesting additional scopes 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: diff --git a/package-lock.json b/package-lock.json index 21207f7..9edc773 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "startup-api-cloudflare", - "version": "0.3.2", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "startup-api-cloudflare", - "version": "0.3.2", + "version": "0.4.0", "license": "Apache-2.0", "dependencies": { "prettier": "^3.8.1", diff --git a/package.json b/package.json index b4bf850..4ade782 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@startup-api/cloudflare", - "version": "0.3.2", + "version": "0.4.0", "license": "Apache-2.0", "publishConfig": { "access": "public" diff --git a/public/users/power-strip.js b/public/users/power-strip.js index 47463e1..4a4fb94 100644 --- a/public/users/power-strip.js +++ b/public/users/power-strip.js @@ -212,6 +212,8 @@ class PowerStrip extends HTMLElement { return ``; } else if (provider === 'patreon') { return ``; + } else if (provider === 'atproto') { + return ``; } return ''; } @@ -221,6 +223,7 @@ class PowerStrip extends HTMLElement { const googleLink = `${this.basePath}/auth/google?return_url=${returnUrl}`; const twitchLink = `${this.basePath}/auth/twitch?return_url=${returnUrl}`; const patreonLink = `${this.basePath}/auth/patreon?return_url=${returnUrl}`; + const atprotoLink = `${this.basePath}/auth/atproto?return_url=${returnUrl}`; const logoutLink = `${this.basePath}/logout?return_url=${returnUrl}`; const providersStr = this.getAttribute('providers') || ''; @@ -257,6 +260,15 @@ class PowerStrip extends HTMLElement { Continue with Patreon `; } + if (providers.includes('atproto')) { + authButtons += ` + + + + + Continue with Bluesky + `; + } let content = ''; let accountSwitcher = ''; @@ -585,6 +597,7 @@ class PowerStrip extends HTMLElement { .provider-badge.google { color: #3c4043; } .provider-badge.twitch { color: #9146FF; } .provider-badge.patreon { color: #FF424D; } + .provider-badge.atproto { color: #0085FF; } .user-info { display: flex; @@ -773,6 +786,16 @@ class PowerStrip extends HTMLElement { border-color: #e63a44; } + .auth-btn.atproto { + background-color: #0085FF; + color: white; + border-color: #0085FF; + } + .auth-btn.atproto:hover { + background-color: #006fd6; + border-color: #006fd6; + } + /* Account Switcher Styling */ .account-list { display: flex; diff --git a/public/users/profile.html b/public/users/profile.html index c18256e..50b77dc 100644 --- a/public/users/profile.html +++ b/public/users/profile.html @@ -433,6 +433,10 @@

Link another account

return ` `; + } else if (provider === 'atproto') { + return ` + + `; } return ''; } diff --git a/src/auth/AtprotoProvider.ts b/src/auth/AtprotoProvider.ts new file mode 100644 index 0000000..b9fd239 --- /dev/null +++ b/src/auth/AtprotoProvider.ts @@ -0,0 +1,282 @@ +import type { StartupAPIEnv } from '../StartupAPIEnv'; +import type { ProviderOptions } from '../schemas/config'; + +import { OAuthProvider, type AuthContext, type ExchangeResult, type OAuthTokenResponse, type UserProfile } from './OAuthProvider'; +import { dpopFetch, generateDpopKey, generatePkce, randomToken } from './atproto/crypto'; +import { fetchProfile, resolveIdentity, type ResolverOptions } from './atproto/identity'; + +const FLOW_COOKIE = 'atproto_flow'; +// Transient flow state lives only for the duration of the redirect round-trip. +const FLOW_TTL_SECONDS = 600; + +/** + * Encrypted, cookie-stored state that must survive the redirect from the authorization server back to + * our callback: the PKCE verifier, the DPoP private key, the latest DPoP nonce, and the dynamically + * discovered endpoints/identity for this specific user. + */ +interface AtprotoFlowState { + state: string; + verifier: string; + dpopKey: JsonWebKey; + dpopNonce?: string; + issuer: string; + tokenEndpoint: string; + pds: string; + did: string; + handle?: string; + returnUrl: string | null; +} + +/** + * Whether the atproto provider is turned on. It has no client secret (public OAuth client), so — like + * the env-credential providers are enabled by the presence of their credentials — atproto is enabled + * simply by including its config key (`providers: { atproto: {} }`). Pass `enabled: false` to opt out + * explicitly (e.g. when the config is built dynamically). + */ +export function isAtprotoEnabled(options?: ProviderOptions): boolean { + return options !== undefined && options.enabled !== false; +} + +/** + * AT Protocol (Bluesky and any atproto PDS) authentication. + * + * Unlike the classic OAuth2 providers, atproto requires PKCE, DPoP-bound tokens, Pushed Authorization + * Requests (PAR), and per-user dynamic endpoints discovered from the identity (handle → DID → PDS → + * authorization server). It is a "public" OAuth client identified by a hosted client-metadata document + * (served at `…/auth/atproto/client-metadata.json`) rather than a client id/secret. + */ +export class AtprotoProvider extends OAuthProvider { + /** The public client id, i.e. the URL of this client's metadata document. */ + private clientMetadataUrl = ''; + private clientUri = ''; + private clientName = 'StartupAPI'; + private resolverOptions: ResolverOptions = {}; + + static create(_env: StartupAPIEnv, redirectBase: string, options?: ProviderOptions): AtprotoProvider | null { + if (!isAtprotoEnabled(options)) return null; + const provider = new AtprotoProvider('', '', redirectBase + '/atproto/callback', 'atproto', options?.scopes); + provider.clientMetadataUrl = redirectBase + '/atproto/client-metadata.json'; + provider.clientUri = new URL(redirectBase).origin; + provider.clientName = options?.clientName?.trim() || 'StartupAPI'; + provider.resolverOptions = { + plcUrl: options?.plcUrl?.trim() || undefined, + dohUrl: options?.dohUrl?.trim() || undefined, + }; + return provider; + } + + getIcon(): string { + // AT Protocol / Bluesky butterfly mark. + return ` + + + `; + } + + /** The OAuth client metadata document (public client, DPoP-bound tokens). */ + getClientMetadata(): Record { + return { + client_id: this.clientMetadataUrl, + client_name: this.clientName, + client_uri: this.clientUri, + redirect_uris: [this.redirectUri], + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + scope: this.buildScope(['atproto']), + token_endpoint_auth_method: 'none', + application_type: 'web', + dpop_bound_access_tokens: true, + }; + } + + async handleExtraRoute(ctx: AuthContext): Promise { + if (ctx.url.pathname === `${ctx.authPath}/atproto/client-metadata.json`) { + return Response.json(this.getClientMetadata()); + } + return null; + } + + /** + * Authorization start. Requires an identifier (`?handle=`); without one we serve a small handle-entry + * form (the standard atproto UX) so we never hardcode any authorization server. With a handle we + * resolve the identity, run a DPoP-protected PAR, persist the flow state in an encrypted cookie, and + * redirect to the discovered authorization endpoint. + */ + async authorize(ctx: AuthContext): Promise { + const identifier = ctx.url.searchParams.get('handle'); + const returnUrl = ctx.url.searchParams.get('return_url'); + if (!identifier || !identifier.trim()) { + return this.renderHandleForm(ctx, returnUrl); + } + + const identity = await resolveIdentity(identifier, this.resolverOptions); + const { verifier, challenge } = await generatePkce(); + const dpopKey = await generateDpopKey(); + const state = randomToken(16); + const scope = this.buildScope(['atproto']); + + // Pushed Authorization Request: hand the request parameters to the authorization server up front + // and receive an opaque request_uri to send the user to. DPoP is required. + const parBody = new URLSearchParams({ + client_id: this.clientMetadataUrl, + redirect_uri: this.redirectUri, + response_type: 'code', + code_challenge: challenge, + code_challenge_method: 'S256', + state, + scope, + login_hint: identity.handle ?? identity.did, + }); + + const { res, nonce } = await dpopFetch( + identity.authServer.pushed_authorization_request_endpoint, + { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: parBody.toString() }, + dpopKey, + ); + if (!res.ok) { + throw new Error(`Pushed authorization request failed: ${res.status} ${await res.text()}`); + } + const par = (await res.json()) as { request_uri: string }; + + const flow: AtprotoFlowState = { + state, + verifier, + dpopKey, + dpopNonce: nonce, + issuer: identity.authServer.issuer, + tokenEndpoint: identity.authServer.token_endpoint, + pds: identity.pds, + did: identity.did, + handle: identity.handle, + returnUrl, + }; + const encrypted = await ctx.cookieManager.encrypt(JSON.stringify(flow)); + + const authUrl = new URL(identity.authServer.authorization_endpoint); + authUrl.searchParams.set('client_id', this.clientMetadataUrl); + authUrl.searchParams.set('request_uri', par.request_uri); + + const headers = new Headers(); + headers.set('Location', authUrl.toString()); + headers.append( + 'Set-Cookie', + `${FLOW_COOKIE}=${encrypted}; Path=${ctx.authPath}/atproto; HttpOnly; Secure; SameSite=Lax; Max-Age=${FLOW_TTL_SECONDS}`, + ); + return new Response(null, { status: 302, headers }); + } + + /** + * Callback. Recover the encrypted flow state, validate `state`/`iss`, run the DPoP-protected token + * exchange against the discovered token endpoint, then resolve a profile from the user's PDS. + */ + async exchange(ctx: AuthContext): Promise { + const errorParam = ctx.url.searchParams.get('error'); + if (errorParam) { + throw new Error(`atproto authorization error: ${errorParam} ${ctx.url.searchParams.get('error_description') ?? ''}`.trim()); + } + + const code = ctx.url.searchParams.get('code'); + if (!code) { + const err = new Error('Missing code') as Error & { status?: number }; + err.status = 400; + throw err; + } + + const encrypted = readCookie(ctx.request.headers.get('Cookie'), FLOW_COOKIE); + if (!encrypted) throw new Error('Missing atproto flow state (cookie expired or blocked)'); + const decrypted = await ctx.cookieManager.decrypt(encrypted); + const flow = decrypted ? (JSON.parse(decrypted) as AtprotoFlowState) : null; + if (!flow) throw new Error('Invalid atproto flow state'); + + if (flow.state !== ctx.url.searchParams.get('state')) throw new Error('State mismatch'); + const iss = ctx.url.searchParams.get('iss'); + if (iss && iss !== flow.issuer) throw new Error('Issuer mismatch'); + + const body = new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: this.redirectUri, + client_id: this.clientMetadataUrl, + code_verifier: flow.verifier, + }); + + const { res } = await dpopFetch( + flow.tokenEndpoint, + { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body.toString() }, + flow.dpopKey, + flow.dpopNonce, + ); + if (!res.ok) { + throw new Error(`Token request failed: ${res.status} ${await res.text()}`); + } + + const tokenData = (await res.json()) as OAuthTokenResponse & { sub?: string }; + const did = tokenData.sub || flow.did; + const { name, picture } = await fetchProfile(flow.pds, did, flow.handle); + + const profile: UserProfile = { id: did, name: name || flow.handle || did, picture }; + const token: OAuthTokenResponse = { + access_token: tokenData.access_token, + refresh_token: tokenData.refresh_token, + expires_in: tokenData.expires_in, + scope: tokenData.scope, + token_type: tokenData.token_type, + }; + + const clearCookie = `${FLOW_COOKIE}=; Path=${ctx.authPath}/atproto; HttpOnly; Secure; SameSite=Lax; Max-Age=0`; + return { token, profile, returnUrl: flow.returnUrl ?? null, setCookies: [clearCookie] }; + } + + /** Minimal handle-entry page shown when the user starts the flow without an identifier. */ + private renderHandleForm(ctx: AuthContext, returnUrl: string | null): Response { + const action = `${ctx.authPath}/atproto`; + const returnField = returnUrl ? `` : ''; + const html = ` + + + + +Sign in with Bluesky / atproto + + + +
+

Sign in with Bluesky / atproto

+ + + ${returnField} + +

Enter your atproto handle (e.g. alice.bsky.social) or DID. Your account's own server handles the login.

+
+ +`; + return new Response(html, { status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8' } }); + } +} + +/** Read a single cookie value from a Cookie header, or undefined. */ +function readCookie(header: string | null, name: string): string | undefined { + if (!header) return undefined; + for (const part of header.split(';')) { + const [key, ...rest] = part.split('='); + if (key.trim() === name) return rest.join('=').trim(); + } + return undefined; +} + +function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} diff --git a/src/auth/OAuthProvider.ts b/src/auth/OAuthProvider.ts index 1afed2d..4a8bfda 100644 --- a/src/auth/OAuthProvider.ts +++ b/src/auth/OAuthProvider.ts @@ -16,6 +16,50 @@ export interface UserProfile { } import type { Entitlements } from '../entitlements/types'; +import type { StartupAPIEnv } from '../StartupAPIEnv'; +import type { CookieManager } from '../CookieManager'; + +/** + * Per-request context handed to a provider's flow hooks. Carries everything needed to run an + * authorization start or callback without the provider reaching back into the router. + */ +export interface AuthContext { + request: Request; + env: StartupAPIEnv; + url: URL; + /** Base URL provider redirect/callback URIs are built from, e.g. `https://host/users/auth`. */ + redirectBase: string; + /** Pathname of `redirectBase`, e.g. `/users/auth`. */ + authPath: string; + /** Configured users path, e.g. `/users/`. */ + usersPath: string; + /** Effective origin (AUTH_ORIGIN override or request origin). */ + origin: string; + cookieManager: CookieManager; +} + +/** + * Result of a successful callback exchange: the token, the resolved user profile, where to send the + * user next, and any extra cookies to emit (e.g. clearing transient flow state). + */ +export interface ExchangeResult { + token: OAuthTokenResponse; + profile: UserProfile; + returnUrl: string | null; + setCookies?: string[]; +} + +/** Decode the base64url state param used by the standard flow back into its return_url. */ +function parseReturnUrl(stateBase64: string | null): string | null { + if (!stateBase64) return null; + try { + const base64 = stateBase64.replace(/-/g, '+').replace(/_/g, '/'); + const stateJson = decodeURIComponent(escape(atob(base64))); + return JSON.parse(stateJson).return_url ?? null; + } catch { + return null; + } +} export abstract class OAuthProvider { protected clientId: string; @@ -58,10 +102,66 @@ export abstract class OAuthProvider { return path === `${authBasePath}/${this.name}/callback`; } - abstract getAuthUrl(state: string): string; abstract getIcon(): string; - abstract getToken(code: string): Promise; - abstract getUserProfile(token: string): Promise; + + /** + * Simple OAuth2 hooks used by the default {@link authorize}/{@link exchange}. Providers whose flow + * fits the classic "redirect → code → token → profile" shape implement these. Providers with a + * heavier flow (e.g. atproto's PKCE/DPoP/PAR) instead override {@link authorize}/{@link exchange} + * and may leave these as the throwing defaults. + */ + getAuthUrl(_state: string): string { + throw new Error(`${this.name}: getAuthUrl is not implemented`); + } + + async getToken(_code: string): Promise { + throw new Error(`${this.name}: getToken is not implemented`); + } + + async getUserProfile(_token: string): Promise { + throw new Error(`${this.name}: getUserProfile is not implemented`); + } + + /** + * Begin the authorization flow. Default: build a base64url `state` (nonce + return_url) and redirect + * to {@link getAuthUrl}. Providers needing async setup, server-side flow state, or custom request + * shapes override this and return their own Response. + */ + async authorize(ctx: AuthContext): Promise { + const returnUrl = ctx.url.searchParams.get('return_url'); + const stateObj = { nonce: Math.random().toString(36).substring(2), return_url: returnUrl }; + const state = btoa(unescape(encodeURIComponent(JSON.stringify(stateObj)))) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); + return Response.redirect(this.getAuthUrl(state), 302); + } + + /** + * Exchange a callback for a token and resolved profile. Default: read `code`, recover the return_url + * from `state`, then {@link getToken} + {@link getUserProfile}. Providers override this for custom + * token exchanges (PKCE/DPoP, dynamic endpoints, etc.). + */ + async exchange(ctx: AuthContext): Promise { + const code = ctx.url.searchParams.get('code'); + if (!code) { + const err = new Error('Missing code') as Error & { status?: number }; + err.status = 400; + throw err; + } + const returnUrl = parseReturnUrl(ctx.url.searchParams.get('state')); + const token = await this.getToken(code); + const profile = await this.getUserProfile(token.access_token); + return { token, profile, returnUrl }; + } + + /** + * Serve any provider-specific auxiliary GET routes mounted under the auth base path (e.g. the atproto + * client-metadata document). Default: not a provider route → null, so the router moves on. + */ + async handleExtraRoute(_ctx: AuthContext): Promise { + return null; + } /** * Whether this provider produces entitlements (memberships / perks). Providers that gate access on diff --git a/src/auth/atproto/crypto.ts b/src/auth/atproto/crypto.ts new file mode 100644 index 0000000..5ebac1e --- /dev/null +++ b/src/auth/atproto/crypto.ts @@ -0,0 +1,119 @@ +/** + * Cryptographic primitives for the AT Protocol OAuth flow: base64url helpers, PKCE, and DPoP + * (RFC 9449) proof generation. atproto OAuth requires PKCE and sender-constrained (DPoP-bound) + * tokens, so every request to the authorization/token endpoints carries a freshly signed ES256 + * proof-of-possession JWT. All of this runs on the WebCrypto API available in Workers. + */ + +/** Encode raw bytes as unpadded base64url. */ +export function base64urlEncode(input: ArrayBuffer | Uint8Array): string { + const bytes = input instanceof Uint8Array ? input : new Uint8Array(input); + let str = ''; + for (const b of bytes) str += String.fromCharCode(b); + return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +/** Encode a UTF-8 string as unpadded base64url. */ +export function base64urlEncodeString(value: string): string { + return base64urlEncode(new TextEncoder().encode(value)); +} + +/** A random unpadded-base64url token of `byteLength` random bytes (used for state, jti, PKCE verifier). */ +export function randomToken(byteLength = 32): string { + return base64urlEncode(crypto.getRandomValues(new Uint8Array(byteLength))); +} + +export interface Pkce { + verifier: string; + challenge: string; +} + +/** Generate a PKCE verifier and its S256 challenge. */ +export async function generatePkce(): Promise { + const verifier = randomToken(32); + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)); + return { verifier, challenge: base64urlEncode(digest) }; +} + +const ES256_PARAMS = { name: 'ECDSA', namedCurve: 'P-256' } as const; + +/** + * Generate an ephemeral ES256 (P-256) keypair for DPoP and return its private JWK. The private JWK is + * persisted in the (encrypted) flow state so the same key can be reused for the token request and any + * later refresh; the public half is embedded in each proof's header. + */ +export async function generateDpopKey(): Promise { + const pair = (await crypto.subtle.generateKey(ES256_PARAMS, true, ['sign', 'verify'])) as CryptoKeyPair; + return (await crypto.subtle.exportKey('jwk', pair.privateKey)) as JsonWebKey; +} + +/** The public portion of a DPoP JWK, as embedded in the proof header. */ +function publicJwk(jwk: JsonWebKey): JsonWebKey { + return { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y }; +} + +/** + * Build a DPoP proof JWT bound to the given HTTP method and URL (htu is normalized to origin+path, per + * RFC 9449). When the server has issued a nonce it must be echoed back in the proof. + */ +export async function createDpopProof(privateJwk: JsonWebKey, htm: string, htu: string, nonce?: string): Promise { + const key = await crypto.subtle.importKey('jwk', privateJwk, ES256_PARAMS, false, ['sign']); + const target = new URL(htu); + const header = { typ: 'dpop+jwt', alg: 'ES256', jwk: publicJwk(privateJwk) }; + const payload: Record = { + jti: randomToken(16), + htm: htm.toUpperCase(), + htu: target.origin + target.pathname, + iat: Math.floor(Date.now() / 1000), + }; + if (nonce) payload.nonce = nonce; + + const signingInput = `${base64urlEncodeString(JSON.stringify(header))}.${base64urlEncodeString(JSON.stringify(payload))}`; + const signature = await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, key, new TextEncoder().encode(signingInput)); + // WebCrypto ECDSA already returns the raw r||s signature that JOSE/ES256 expects. + return `${signingInput}.${base64urlEncode(signature)}`; +} + +export interface DpopResult { + res: Response; + /** The most recent server-issued DPoP nonce, to be reused on the next request. */ + nonce?: string; +} + +/** + * POST to a DPoP-protected endpoint, transparently handling the `use_dpop_nonce` challenge: the first + * request is sent without (or with the cached) nonce; if the server demands a fresh nonce we retry once + * with it. Returns the final response and the latest nonce so the caller can chain requests (PAR → + * token) without re-fetching one. + */ +export async function dpopFetch(url: string, init: RequestInit, privateJwk: JsonWebKey, nonce?: string): Promise { + let currentNonce = nonce; + const method = (init.method || 'POST').toUpperCase(); + + for (let attempt = 0; attempt < 2; attempt++) { + const proof = await createDpopProof(privateJwk, method, url, currentNonce); + const headers = new Headers(init.headers); + headers.set('DPoP', proof); + const res = await fetch(url, { ...init, headers }); + const serverNonce = res.headers.get('DPoP-Nonce') ?? undefined; + + if ((res.status === 400 || res.status === 401) && serverNonce && attempt === 0) { + let needsNonce = false; + try { + const body = (await res.clone().json()) as { error?: string }; + needsNonce = body?.error === 'use_dpop_nonce'; + } catch { + // Non-JSON body; fall through and surface the original response. + } + if (needsNonce) { + currentNonce = serverNonce; + continue; + } + } + + return { res, nonce: serverNonce ?? currentNonce }; + } + + // Unreachable: the loop either returns a response or retries exactly once then returns. + throw new Error('dpopFetch: exhausted retries'); +} diff --git a/src/auth/atproto/identity.ts b/src/auth/atproto/identity.ts new file mode 100644 index 0000000..7bd45bf --- /dev/null +++ b/src/auth/atproto/identity.ts @@ -0,0 +1,178 @@ +/** + * AT Protocol identity resolution. Given a user-supplied handle or DID we walk the full discovery + * chain — with NO hardcoded Bluesky/PDS hosts — to find the authorization server that owns the + * identity: + * + * handle ──▶ DID (HTTPS `.well-known/atproto-did`, then DNS TXT `_atproto.` via DoH) + * DID ──▶ DID document (did:plc via a PLC directory, did:web via the domain's `.well-known`) + * DID doc──▶ PDS endpoint (the `#atproto_pds` service) + * PDS ──▶ auth server (`.well-known/oauth-protected-resource` → `.well-known/oauth-authorization-server`) + * + * The PLC directory and DNS-over-HTTPS resolver are generic, swappable infrastructure (overridable via + * env), not identity providers — every account hosts its own PDS and authorization server, which we + * discover dynamically here. + */ + +const DID_PREFIX = /^did:(plc|web):/; + +export interface AuthServerMetadata { + issuer: string; + authorization_endpoint: string; + token_endpoint: string; + pushed_authorization_request_endpoint: string; + [key: string]: unknown; +} + +export interface ResolvedIdentity { + did: string; + handle?: string; + pds: string; + authServer: AuthServerMetadata; +} + +export interface ResolverOptions { + /** PLC directory base URL for resolving did:plc. Defaults to the canonical https://plc.directory. */ + plcUrl?: string; + /** DNS-over-HTTPS endpoint (RFC 8484 JSON) for the `_atproto.` TXT fallback. */ + dohUrl?: string; +} + +const DEFAULT_PLC_URL = 'https://plc.directory'; +const DEFAULT_DOH_URL = 'https://cloudflare-dns.com/dns-query'; + +async function fetchJson(url: string, init?: RequestInit): Promise { + const res = await fetch(url, init); + if (!res.ok) throw new Error(`Request to ${url} failed: ${res.status} ${res.statusText}`); + return res.json(); +} + +/** Resolve a handle to a DID via the HTTPS well-known method, falling back to DNS TXT over DoH. */ +async function resolveHandleToDid(handle: string, options: ResolverOptions): Promise { + // 1. HTTPS well-known (works for any host that serves it; no third-party dependency). + try { + const res = await fetch(`https://${handle}/.well-known/atproto-did`, { redirect: 'error' }); + if (res.ok) { + const did = (await res.text()).trim(); + if (DID_PREFIX.test(did)) return did; + } + } catch { + // Fall through to DNS-based resolution. + } + + // 2. DNS TXT `_atproto.` via DNS-over-HTTPS. + try { + const dohUrl = new URL(options.dohUrl || DEFAULT_DOH_URL); + dohUrl.searchParams.set('name', `_atproto.${handle}`); + dohUrl.searchParams.set('type', 'TXT'); + const data = await fetchJson(dohUrl.toString(), { headers: { accept: 'application/dns-json' } }); + for (const answer of data.Answer ?? []) { + const txt = String(answer.data ?? '').replace(/^"|"$/g, ''); + if (txt.startsWith('did=')) { + const did = txt.slice(4).trim(); + if (DID_PREFIX.test(did)) return did; + } + } + } catch { + // Fall through to the error below. + } + + throw new Error(`Could not resolve handle "${handle}" to a DID`); +} + +/** Fetch and return the DID document for a did:plc or did:web identifier. */ +async function resolveDidDocument(did: string, options: ResolverOptions): Promise { + if (did.startsWith('did:plc:')) { + const base = (options.plcUrl || DEFAULT_PLC_URL).replace(/\/$/, ''); + return fetchJson(`${base}/${did}`); + } + if (did.startsWith('did:web:')) { + // did:web:example.com[:path...] → https://example.com[/path...]/did.json (or /.well-known/did.json). + const rest = did.slice('did:web:'.length); + const segments = rest.split(':').map((s) => decodeURIComponent(s)); + const host = segments.shift(); + if (!host) throw new Error(`Malformed did:web identifier: ${did}`); + const path = segments.length ? `/${segments.join('/')}/did.json` : '/.well-known/did.json'; + return fetchJson(`https://${host}${path}`); + } + throw new Error(`Unsupported DID method: ${did}`); +} + +/** Extract the PDS service endpoint from a DID document. */ +function getPdsEndpoint(didDoc: any): string { + const services: any[] = Array.isArray(didDoc?.service) ? didDoc.service : []; + const pds = services.find( + (s) => s?.id === '#atproto_pds' || (typeof s?.id === 'string' && s.id.endsWith('#atproto_pds')) || s?.type === 'AtprotoPersonalDataServer', + ); + const endpoint = typeof pds?.serviceEndpoint === 'string' ? pds.serviceEndpoint : pds?.serviceEndpoint?.uri; + if (!endpoint) throw new Error('DID document does not advertise an atproto PDS endpoint'); + return endpoint.replace(/\/$/, ''); +} + +/** Extract the primary handle (`at://`) from a DID document's alsoKnownAs, if present. */ +function getHandle(didDoc: any): string | undefined { + const aka: string[] = Array.isArray(didDoc?.alsoKnownAs) ? didDoc.alsoKnownAs : []; + const at = aka.find((a) => typeof a === 'string' && a.startsWith('at://')); + return at ? at.slice('at://'.length) : undefined; +} + +/** Resolve a PDS to its authorization server metadata via the protected-resource indirection. */ +async function resolveAuthServer(pds: string): Promise { + const protectedResource = await fetchJson(new URL('/.well-known/oauth-protected-resource', pds).toString()); + const issuer: string | undefined = protectedResource?.authorization_servers?.[0]; + if (!issuer) throw new Error(`PDS ${pds} does not declare an authorization server`); + + const metadata = (await fetchJson(new URL('/.well-known/oauth-authorization-server', issuer).toString())) as AuthServerMetadata; + if (!metadata.authorization_endpoint || !metadata.token_endpoint || !metadata.pushed_authorization_request_endpoint) { + throw new Error(`Authorization server ${issuer} is missing required OAuth endpoints (PAR is mandatory for atproto)`); + } + return metadata; +} + +/** + * Full identity → authorization-server resolution. Accepts a handle (optionally `@`-prefixed) or a DID. + */ +export async function resolveIdentity(input: string, options: ResolverOptions = {}): Promise { + const cleaned = input.trim().replace(/^@/, ''); + if (!cleaned) throw new Error('Empty atproto identifier'); + + const isDid = cleaned.startsWith('did:'); + let handle: string | undefined = isDid ? undefined : cleaned; + const did = isDid ? cleaned : await resolveHandleToDid(cleaned, options); + + const didDoc = await resolveDidDocument(did, options); + const pds = getPdsEndpoint(didDoc); + if (!handle) handle = getHandle(didDoc); + + const authServer = await resolveAuthServer(pds); + return { did, handle, pds, authServer }; +} + +/** + * Fetch a public actor profile record from the user's PDS to populate display name and avatar. Best + * effort: getRecord is an unauthenticated read, and any failure falls back to the handle/DID. + */ +export async function fetchProfile(pds: string, did: string, handle?: string): Promise<{ name?: string; picture?: string }> { + try { + const url = new URL('/xrpc/com.atproto.repo.getRecord', pds); + url.searchParams.set('repo', did); + url.searchParams.set('collection', 'app.bsky.actor.profile'); + url.searchParams.set('rkey', 'self'); + + const res = await fetch(url.toString()); + if (!res.ok) return { name: handle }; + + const data = (await res.json()) as { value?: { displayName?: string; avatar?: { ref?: { $link?: string } } } }; + const value = data.value ?? {}; + let picture: string | undefined; + const cid = value.avatar?.ref?.$link; + if (cid) { + const blob = new URL('/xrpc/com.atproto.sync.getBlob', pds); + blob.searchParams.set('did', did); + blob.searchParams.set('cid', cid); + picture = blob.toString(); + } + return { name: value.displayName || handle, picture }; + } catch { + return { name: handle }; + } +} diff --git a/src/auth/index.ts b/src/auth/index.ts index e87c5f1..4b364b3 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -4,6 +4,7 @@ import { CookieManager } from '../CookieManager'; import { refreshEntitlements } from '../entitlements/service'; import { computeRedirectBase, createProviders } from './providers'; import type { ProviderConfigs } from './providers'; +import type { AuthContext, ExchangeResult, OAuthProvider } from './OAuthProvider'; export async function handleAuth( request: Request, @@ -25,21 +26,22 @@ export async function handleAuth( // Instantiate active providers const activeProviders = createProviders(env, redirectBase, providerConfigs); + const ctx: AuthContext = { request, env, url, redirectBase, authPath, usersPath, origin, cookieManager }; + + // Provider-specific auxiliary routes (e.g. the atproto client-metadata document). + for (const provider of activeProviders) { + const res = await provider.handleExtraRoute(ctx); + if (res) return res; + } + // Handle Auth Start for (const provider of activeProviders) { if (provider.isMatch(path, authPath)) { - const returnUrl = url.searchParams.get('return_url'); - const stateObj = { - nonce: Math.random().toString(36).substring(2), - return_url: returnUrl, - }; - // Use robust base64 encoding for state - const state = btoa(unescape(encodeURIComponent(JSON.stringify(stateObj)))) - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, ''); - const authUrl = provider.getAuthUrl(state); - return Response.redirect(authUrl, 302); + try { + return await provider.authorize(ctx); + } catch (e) { + return new Response(`Auth failed: ${e instanceof Error ? e.message : String(e)}`, { status: 500 }); + } } } @@ -47,207 +49,205 @@ export async function handleAuth( for (const provider of activeProviders) { if (provider.isCallback(path, authPath)) { console.log(`[Auth] Callback received for ${provider.name}`); - const code = url.searchParams.get('code'); - if (!code) return new Response('Missing code', { status: 400 }); - - const stateBase64 = url.searchParams.get('state'); - let returnUrl: string | null = null; - if (stateBase64) { - try { - // Robust base64 decoding - const base64 = stateBase64.replace(/-/g, '+').replace(/_/g, '/'); - const stateJson = decodeURIComponent(escape(atob(base64))); - const stateObj = JSON.parse(stateJson); - returnUrl = stateObj.return_url; - } catch (e) { - console.error('Failed to parse state', e); - } + try { + const result = await provider.exchange(ctx); + return await finishLogin(provider, result, ctx); + } catch (e) { + const status = (e as { status?: number })?.status ?? 500; + return new Response(`Auth failed: ${e instanceof Error ? e.message : String(e)}`, { status }); } + } + } - try { - const token = await provider.getToken(code); - const profile = await provider.getUserProfile(token.access_token); - - const systemStub = env.SYSTEM.get(env.SYSTEM.idFromName('global')); - - // 1. Try to resolve existing user by credential - const credentialStub = env.CREDENTIAL.get(env.CREDENTIAL.idFromName(provider.name)); - const resolveData = await credentialStub.get(profile.id); - - let userIdStr: string | null = null; - let staleSessionId: string | null = null; - - if (resolveData) { - userIdStr = resolveData.user_id; - } else { - // 2. Not found, check if user is already logged in (to link account) - const cookieHeader = request.headers.get('Cookie'); - if (cookieHeader) { - const cookies = cookieHeader.split(';').reduce( - (acc, cookie) => { - const [key, value] = cookie.split('=').map((c) => c.trim()); - if (key && value) acc[key] = value; - return acc; - }, - {} as Record, - ); - const sessionCookieEncrypted = cookies['session_id']; - if (sessionCookieEncrypted) { - const sessionCookie = await cookieManager.decrypt(sessionCookieEncrypted); - if (sessionCookie && sessionCookie.includes(':')) { - const parts = sessionCookie.split(':'); - staleSessionId = parts[0]; - userIdStr = parts[1]; - } - } - } - } + return new Response('Auth route not found', { status: 404 }); +} - if (userIdStr) { - // Verify user still exists (has a profile) - const userStub = env.USER.get(env.USER.idFromString(userIdStr)); - const profileData = await userStub.getProfile(); - if (Object.keys(profileData).length === 0) { - // User was deleted! - if (staleSessionId) { - try { - await userStub.deleteSession(staleSessionId); - } catch (_e) { - // ignore - } - } - userIdStr = null; - } +/** + * Shared post-exchange login finalization, provider-agnostic: resolve or create the user, link the + * credential, fetch login-time entitlements, ensure a personal account exists, mint a session and set + * the session cookie. `result.setCookies` lets a provider emit additional cookies (e.g. clearing + * transient flow state). + */ +async function finishLogin(provider: OAuthProvider, result: ExchangeResult, ctx: AuthContext): Promise { + const { env, request, usersPath, origin, cookieManager } = ctx; + const { token, profile, returnUrl } = result; + + const systemStub = env.SYSTEM.get(env.SYSTEM.idFromName('global')); + + // 1. Try to resolve existing user by credential + const credentialStub = env.CREDENTIAL.get(env.CREDENTIAL.idFromName(provider.name)); + const resolveData = await credentialStub.get(profile.id); + + let userIdStr: string | null = null; + let staleSessionId: string | null = null; + + if (resolveData) { + userIdStr = resolveData.user_id; + } else { + // 2. Not found, check if user is already logged in (to link account) + const cookieHeader = request.headers.get('Cookie'); + if (cookieHeader) { + const cookies = cookieHeader.split(';').reduce( + (acc, cookie) => { + const [key, value] = cookie.split('=').map((c) => c.trim()); + if (key && value) acc[key] = value; + return acc; + }, + {} as Record, + ); + const sessionCookieEncrypted = cookies['session_id']; + if (sessionCookieEncrypted) { + const sessionCookie = await cookieManager.decrypt(sessionCookieEncrypted); + if (sessionCookie && sessionCookie.includes(':')) { + const parts = sessionCookie.split(':'); + staleSessionId = parts[0]; + userIdStr = parts[1]; } + } + } + } - const isNewUser = !userIdStr; - const id = userIdStr ? env.USER.idFromString(userIdStr) : env.USER.newUniqueId(); - const userStub = env.USER.get(id); - userIdStr = id.toString(); - - // Fetch and Store Avatar (Only for new users) - if (isNewUser && profile.picture) { - try { - const picRes = await fetch(profile.picture); - if (picRes.ok) { - const picBlob = await picRes.arrayBuffer(); - await userStub.storeImage('avatar', picBlob, picRes.headers.get('Content-Type') || 'image/jpeg'); - // Update profile.picture to point to our worker - profile.picture = usersPath + 'me/avatar'; - } - } catch (e) { - console.error('Failed to fetch avatar', e); - } + if (userIdStr) { + // Verify user still exists (has a profile) + const userStub = env.USER.get(env.USER.idFromString(userIdStr)); + const profileData = await userStub.getProfile(); + if (Object.keys(profileData).length === 0) { + // User was deleted! + if (staleSessionId) { + try { + await userStub.deleteSession(staleSessionId); + } catch (_e) { + // ignore } + } + userIdStr = null; + } + } - // Register credential in provider-specific CredentialDO - await credentialStub.put({ - user_id: userIdStr, + const isNewUser = !userIdStr; + const id = userIdStr ? env.USER.idFromString(userIdStr) : env.USER.newUniqueId(); + const userStub = env.USER.get(id); + userIdStr = id.toString(); + + // Fetch and Store Avatar (Only for new users) + if (isNewUser && profile.picture) { + try { + const picRes = await fetch(profile.picture); + if (picRes.ok) { + const picBlob = await picRes.arrayBuffer(); + await userStub.storeImage('avatar', picBlob, picRes.headers.get('Content-Type') || 'image/jpeg'); + // Update profile.picture to point to our worker + profile.picture = usersPath + 'me/avatar'; + } + } catch (e) { + console.error('Failed to fetch avatar', e); + } + } + + // Register credential in provider-specific CredentialDO + await credentialStub.put({ + user_id: userIdStr, + subject_id: profile.id, + access_token: token.access_token, + refresh_token: token.refresh_token, + expires_at: token.expires_in ? Date.now() + token.expires_in * 1000 : undefined, + scope: token.scope, + profile_data: profile, + }); + + // Register credential mapping in UserDO + await userStub.addCredential(provider.name, profile.id); + + // Login-time entitlement fetch: providers that support entitlements (e.g. Patreon) get an + // initial entitlement snapshot now, so gating works even when no freshness mechanism is + // configured. Best-effort — never block or fail login on an entitlement error. + if (provider.supportsEntitlements()) { + try { + await refreshEntitlements( + env, + provider, + { subject_id: profile.id, + user_id: userIdStr, access_token: token.access_token, refresh_token: token.refresh_token, expires_at: token.expires_in ? Date.now() + token.expires_in * 1000 : undefined, - scope: token.scope, + scope: typeof token.scope === 'string' ? token.scope : undefined, profile_data: profile, - }); - - // Register credential mapping in UserDO - await userStub.addCredential(provider.name, profile.id); - - // Login-time entitlement fetch: providers that support entitlements (e.g. Patreon) get an - // initial entitlement snapshot now, so gating works even when no freshness mechanism is - // configured. Best-effort — never block or fail login on an entitlement error. - if (provider.supportsEntitlements()) { - try { - await refreshEntitlements( - env, - provider, - { - subject_id: profile.id, - user_id: userIdStr, - access_token: token.access_token, - refresh_token: token.refresh_token, - expires_at: token.expires_in ? Date.now() + token.expires_in * 1000 : undefined, - scope: typeof token.scope === 'string' ? token.scope : undefined, - profile_data: profile, - }, - 'oauth', - ); - } catch (e) { - console.error('[auth] Login-time entitlement fetch failed', e); - } - } + }, + 'oauth', + ); + } catch (e) { + console.error('[auth] Login-time entitlement fetch failed', e); + } + } - // Register User in SystemDO index (Only for new users) - if (isNewUser) { - await userStub.updateProfile(profile); - await systemStub.registerUser({ - id: userIdStr, - name: profile.name || userIdStr, - email: profile.email, - provider: provider.name, - }); - } + // Register User in SystemDO index (Only for new users) + if (isNewUser) { + await userStub.updateProfile(profile); + await systemStub.registerUser({ + id: userIdStr, + name: profile.name || userIdStr, + email: profile.email, + provider: provider.name, + }); + } - // Ensure user has at least one account - const memberships = await userStub.getMemberships(); - - if (memberships.length === 0) { - // Create a personal account - const accountId = env.ACCOUNT.newUniqueId(); - const accountStub = env.ACCOUNT.get(accountId); - const accountIdStr = accountId.toString(); - - // Initialize account info - await accountStub.updateInfo({ - name: `${profile.name || userIdStr}'s Account`, - personal: true, - }); - - // Register Account in SystemDO - await systemStub.registerAccount({ - id: accountIdStr, - name: `${profile.name || profile.id}'s Account`, - status: 'active', - plan: 'free', - }); - - // Add user as ADMIN to the account - await accountStub.addMember(id.toString(), 1); - - // Add membership to user - await userStub.addMembership(accountIdStr, 1, true); - } + // Ensure user has at least one account + const memberships = await userStub.getMemberships(); + + if (memberships.length === 0) { + // Create a personal account + const accountId = env.ACCOUNT.newUniqueId(); + const accountStub = env.ACCOUNT.get(accountId); + const accountIdStr = accountId.toString(); + + // Initialize account info + await accountStub.updateInfo({ + name: `${profile.name || userIdStr}'s Account`, + personal: true, + }); + + // Register Account in SystemDO + await systemStub.registerAccount({ + id: accountIdStr, + name: `${profile.name || profile.id}'s Account`, + status: 'active', + plan: 'free', + }); + + // Add user as ADMIN to the account + await accountStub.addMember(id.toString(), 1); + + // Add membership to user + await userStub.addMembership(accountIdStr, 1, true); + } - // Create Session - const session = await userStub.createSession({ provider: provider.name }); - - // Set cookie and redirect - const encryptedSession = await cookieManager.encrypt(`${session.sessionId}:${userIdStr}`); - const headers = new Headers(); - headers.set('Set-Cookie', `session_id=${encryptedSession}; Path=/; HttpOnly; Secure; SameSite=Lax`); - - let redirectUrl = !isNewUser ? usersPath + 'profile.html' : '/'; - if (returnUrl) { - try { - const parsedReturn = new URL(returnUrl, origin); - if (parsedReturn.origin === origin) { - redirectUrl = parsedReturn.toString(); - } - } catch (_e) { - if (returnUrl.startsWith('/')) { - redirectUrl = returnUrl; - } - } - } + // Create Session + const session = await userStub.createSession({ provider: provider.name }); - headers.set('Location', redirectUrl); - return new Response(null, { status: 302, headers }); - } catch (e) { - return new Response(`Auth failed: ${e instanceof Error ? e.message : String(e)}`, { status: 500 }); + // Set cookie and redirect + const encryptedSession = await cookieManager.encrypt(`${session.sessionId}:${userIdStr}`); + const headers = new Headers(); + headers.set('Set-Cookie', `session_id=${encryptedSession}; Path=/; HttpOnly; Secure; SameSite=Lax`); + for (const cookie of result.setCookies ?? []) { + headers.append('Set-Cookie', cookie); + } + + let redirectUrl = !isNewUser ? usersPath + 'profile.html' : '/'; + if (returnUrl) { + try { + const parsedReturn = new URL(returnUrl, origin); + if (parsedReturn.origin === origin) { + redirectUrl = parsedReturn.toString(); + } + } catch (_e) { + if (returnUrl.startsWith('/')) { + redirectUrl = returnUrl; } } } - return new Response('Auth route not found', { status: 404 }); + headers.set('Location', redirectUrl); + return new Response(null, { status: 302, headers }); } diff --git a/src/auth/providers.ts b/src/auth/providers.ts index 40bad7e..e97f23c 100644 --- a/src/auth/providers.ts +++ b/src/auth/providers.ts @@ -4,6 +4,7 @@ import { OAuthProvider } from './OAuthProvider'; import { GoogleProvider } from './GoogleProvider'; import { TwitchProvider } from './TwitchProvider'; import { PatreonProvider } from './PatreonProvider'; +import { AtprotoProvider } from './AtprotoProvider'; export type ProviderConfigs = Record; @@ -25,6 +26,7 @@ export function createProviders(env: StartupAPIEnv, redirectBase: string, provid GoogleProvider.create(env, redirectBase, providerConfigs.google), TwitchProvider.create(env, redirectBase, providerConfigs.twitch), PatreonProvider.create(env, redirectBase, providerConfigs.patreon), + AtprotoProvider.create(env, redirectBase, providerConfigs.atproto), ].filter((p): p is OAuthProvider => p !== null); } diff --git a/src/createStartupAPI.ts b/src/createStartupAPI.ts index 645e935..36b851b 100644 --- a/src/createStartupAPI.ts +++ b/src/createStartupAPI.ts @@ -167,7 +167,7 @@ export function createStartupAPI(config: StartupAPIConfig = {}) { const isAccounts = subPath === 'accounts.html' || subPath === 'accounts'; if (isProfile || isAccounts) { - return handleSSR(request, env, url, usersPath, cookieManager); + return handleSSR(request, env, url, usersPath, cookieManager, providerConfigs); } } @@ -262,7 +262,7 @@ export function createStartupAPI(config: StartupAPIConfig = {}) { // Admin Routes if (url.pathname.startsWith(usersPath + 'admin/')) { - return handleAdmin(request, env, usersPath, cookieManager); + return handleAdmin(request, env, usersPath, cookieManager, providerConfigs); } // Intercept requests to usersPath and serve them from the public/users directory. @@ -336,7 +336,7 @@ export function createStartupAPI(config: StartupAPIConfig = {}) { return denyResponse(decision, { usersPath, returnUrl, - activeProviders: getActiveProviders(env), + activeProviders: getActiveProviders(env, providerConfigs), authenticated, request, env, @@ -345,7 +345,7 @@ export function createStartupAPI(config: StartupAPIConfig = {}) { } const response = await originFetch(newRequest); - const providers = getActiveProviders(env); + const providers = getActiveProviders(env, providerConfigs); return injectPowerStrip(response, usersPath, providers); } diff --git a/src/handlers/admin.ts b/src/handlers/admin.ts index d712d0b..01c4212 100644 --- a/src/handlers/admin.ts +++ b/src/handlers/admin.ts @@ -1,6 +1,7 @@ import { StartupAPIEnv } from '../StartupAPIEnv'; import { CookieManager } from '../CookieManager'; import { getUserFromSession, checkAndClearStaleSession, isAdmin, parseCookies, getActiveProviders } from './utils'; +import type { ProviderConfigs } from '../auth/providers'; import { Plan } from '../billing/Plan'; import { UserProfileSchema } from '../schemas/user'; import { SystemAccountSchema, MemberSchema } from '../schemas/account'; @@ -11,6 +12,7 @@ export async function handleAdmin( env: StartupAPIEnv, usersPath: string, cookieManager: CookieManager, + providerConfigs: ProviderConfigs = {}, ): Promise { const user = await getUserFromSession(request, env, cookieManager); if (!user || !isAdmin(user, env)) { @@ -31,7 +33,7 @@ export async function handleAdmin( html = html.replace(/\{\{ssr:([a-z0-9_]+)\}\}/g, (match, key) => { const replacements: Record = { plans_json: JSON.stringify(Plan.getAll()).replace(/"/g, '"'), - providers: getActiveProviders(env).join(','), + providers: getActiveProviders(env, providerConfigs).join(','), }; return replacements[key] !== undefined ? replacements[key] : match; }); diff --git a/src/handlers/ssr.ts b/src/handlers/ssr.ts index 16186c6..21c327a 100644 --- a/src/handlers/ssr.ts +++ b/src/handlers/ssr.ts @@ -1,6 +1,7 @@ import { StartupAPIEnv } from '../StartupAPIEnv'; import { CookieManager } from '../CookieManager'; import { getUserFromSession, checkAndClearStaleSession, isAdmin, getActiveProviders } from './utils'; +import type { ProviderConfigs } from '../auth/providers'; import { Plan } from '../billing/Plan'; export async function handleSSR( @@ -9,6 +10,7 @@ export async function handleSSR( url: URL, usersPath: string, cookieManager: CookieManager, + providerConfigs: ProviderConfigs = {}, ): Promise { const user = await getUserFromSession(request, env, cookieManager); if (!user) { @@ -90,7 +92,7 @@ export async function handleSSR( // Prepare SSR values const replacements: Record = { plans_json: JSON.stringify(Plan.getAll()).replace(/"/g, '"'), - providers: getActiveProviders(env).join(','), + providers: getActiveProviders(env, providerConfigs).join(','), profile_json: JSON.stringify(data).replace(/"/g, '"'), credentials_json: JSON.stringify(credentials).replace(/"/g, '"'), profile_name: data.profile.name || 'Anonymous', @@ -105,7 +107,7 @@ export async function handleSSR( : '', nav_account_display: account && (account.role === 1 || data.is_admin) ? 'display: block;' : 'display: none;', credentials_list_html: renderCredentialsList(credentials, data.credential?.provider), - link_credentials_html: renderLinkCredentialsList(getActiveProviders(env), url.href), + link_credentials_html: renderLinkCredentialsList(getActiveProviders(env, providerConfigs), url.href), }; if (account) { @@ -212,6 +214,8 @@ function getProviderIcon(provider: string): string { return ''; } else if (provider === 'patreon') { return ''; + } else if (provider === 'atproto') { + return ''; } return ''; } diff --git a/src/handlers/utils.ts b/src/handlers/utils.ts index 0578895..05aa4c2 100644 --- a/src/handlers/utils.ts +++ b/src/handlers/utils.ts @@ -1,7 +1,9 @@ import { StartupAPIEnv } from '../StartupAPIEnv'; import { CookieManager } from '../CookieManager'; +import { isAtprotoEnabled } from '../auth/AtprotoProvider'; +import type { ProviderConfigs } from '../auth/providers'; -export function getActiveProviders(env: StartupAPIEnv): string[] { +export function getActiveProviders(env: StartupAPIEnv, providerConfigs: ProviderConfigs = {}): string[] { const providers: string[] = []; if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) { providers.push('google'); @@ -12,6 +14,10 @@ export function getActiveProviders(env: StartupAPIEnv): string[] { if (env.PATREON_CLIENT_ID && env.PATREON_CLIENT_SECRET) { providers.push('patreon'); } + // atproto has no env credentials; it is enabled purely via factory config. + if (isAtprotoEnabled(providerConfigs.atproto)) { + providers.push('atproto'); + } return providers; } diff --git a/src/schemas/config.ts b/src/schemas/config.ts index f89a1b9..87235e2 100644 --- a/src/schemas/config.ts +++ b/src/schemas/config.ts @@ -28,6 +28,12 @@ export const ProviderOptionsSchema = z.object({ scopes: z.union([z.string(), z.array(z.string())]).optional(), /** Patreon only: restrict entitlements to a single campaign id. */ campaignId: z.string().optional(), + /** atproto only: display name advertised in the client-metadata document. Default: "StartupAPI". */ + clientName: z.string().optional(), + /** atproto only: override the PLC directory used to resolve did:plc identities. Default: https://plc.directory. */ + plcUrl: z.string().optional(), + /** atproto only: override the DNS-over-HTTPS resolver used for handle resolution. */ + dohUrl: z.string().optional(), freshness: ProviderFreshnessSchema.optional(), }); diff --git a/test/atproto.spec.ts b/test/atproto.spec.ts new file mode 100644 index 0000000..8e5dd55 --- /dev/null +++ b/test/atproto.spec.ts @@ -0,0 +1,233 @@ +import { env, createExecutionContext, waitOnExecutionContext } from 'cloudflare:test'; +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { createStartupAPI } from '../src/createStartupAPI'; +import { CookieManager } from '../src/CookieManager'; + +// atproto is a public OAuth client (no secret); including its config key enables it. +const atprotoConfig = { providers: { atproto: {} } } as const; + +const realFetch = globalThis.fetch; +afterEach(() => { + globalThis.fetch = realFetch; + vi.restoreAllMocks(); +}); + +/** + * Mock the full atproto discovery + OAuth chain. `parCalls` records every PAR/token request so tests can + * assert DPoP/PKCE behavior. The PAR endpoint deliberately demands a DPoP nonce on the first hit to + * exercise the `use_dpop_nonce` retry. + */ +function installAtprotoMocks(opts: { onPar?: (init: RequestInit) => void; onToken?: (init: RequestInit) => void } = {}) { + let parHits = 0; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : (input as Request).url; + const headers = new Headers(init?.headers); + + if (url === 'https://alice.test/.well-known/atproto-did') { + return new Response('did:plc:alicedid', { status: 200 }); + } + if (url === 'https://plc.directory/did:plc:alicedid') { + return Response.json({ + id: 'did:plc:alicedid', + alsoKnownAs: ['at://alice.test'], + service: [{ id: '#atproto_pds', type: 'AtprotoPersonalDataServer', serviceEndpoint: 'https://pds.test' }], + }); + } + if (url === 'https://pds.test/.well-known/oauth-protected-resource') { + return Response.json({ authorization_servers: ['https://auth.test'] }); + } + if (url === 'https://auth.test/.well-known/oauth-authorization-server') { + return Response.json({ + issuer: 'https://auth.test', + authorization_endpoint: 'https://auth.test/authorize', + token_endpoint: 'https://auth.test/token', + pushed_authorization_request_endpoint: 'https://auth.test/par', + }); + } + if (url === 'https://auth.test/par') { + opts.onPar?.({ ...init, headers }); + parHits++; + // First call: demand a DPoP nonce. Second call: succeed. + if (parHits === 1) { + return new Response(JSON.stringify({ error: 'use_dpop_nonce' }), { status: 400, headers: { 'DPoP-Nonce': 'nonce-1' } }); + } + return new Response(JSON.stringify({ request_uri: 'urn:ietf:params:oauth:request_uri:abc', expires_in: 60 }), { + status: 201, + headers: { 'DPoP-Nonce': 'nonce-2' }, + }); + } + if (url === 'https://auth.test/token') { + opts.onToken?.({ ...init, headers }); + return Response.json({ + access_token: 'atproto-access-token', + token_type: 'DPoP', + refresh_token: 'atproto-refresh-token', + expires_in: 3600, + scope: 'atproto', + sub: 'did:plc:alicedid', + }); + } + if (url.startsWith('https://pds.test/xrpc/com.atproto.repo.getRecord')) { + return Response.json({ value: { displayName: 'Alice in AT' } }); + } + throw new Error(`Unexpected fetch in test: ${url}`); + }) as typeof fetch; +} + +describe('atproto provider', () => { + it('serves the OAuth client-metadata document', async () => { + const api = createStartupAPI(atprotoConfig); + const ctx = createExecutionContext(); + const res = await api.fetch(new Request('http://example.com/users/auth/atproto/client-metadata.json'), env, ctx); + await waitOnExecutionContext(ctx); + + expect(res.status).toBe(200); + const meta = (await res.json()) as Record; + expect(meta.client_id).toBe('http://example.com/users/auth/atproto/client-metadata.json'); + expect(meta.redirect_uris).toEqual(['http://example.com/users/auth/atproto/callback']); + expect(meta.token_endpoint_auth_method).toBe('none'); + expect(meta.dpop_bound_access_tokens).toBe(true); + expect(meta.scope).toContain('atproto'); + }); + + it('shows a handle-entry form when no identifier is provided', async () => { + const api = createStartupAPI(atprotoConfig); + const ctx = createExecutionContext(); + const res = await api.fetch(new Request('http://example.com/users/auth/atproto'), env, ctx); + await waitOnExecutionContext(ctx); + + expect(res.status).toBe(200); + expect(res.headers.get('Content-Type')).toContain('text/html'); + const html = await res.text(); + expect(html).toContain('name="handle"'); + }); + + it('resolves identity, performs PAR with DPoP + PKCE (with nonce retry), and redirects', async () => { + let parInit: RequestInit | undefined; + let parDpopProofs = 0; + installAtprotoMocks({ + onPar: (init) => { + parInit = init; + if (new Headers(init.headers).get('DPoP')) parDpopProofs++; + }, + }); + + const api = createStartupAPI(atprotoConfig); + const ctx = createExecutionContext(); + const res = await api.fetch( + new Request('http://example.com/users/auth/atproto?handle=alice.test&return_url=/dashboard'), + env, + ctx, + ); + await waitOnExecutionContext(ctx); + + expect(res.status).toBe(302); + const location = new URL(res.headers.get('Location')!); + expect(location.origin + location.pathname).toBe('https://auth.test/authorize'); + expect(location.searchParams.get('request_uri')).toBe('urn:ietf:params:oauth:request_uri:abc'); + expect(location.searchParams.get('client_id')).toBe('http://example.com/users/auth/atproto/client-metadata.json'); + + // PAR carried a DPoP proof on each attempt and the PKCE challenge. + expect(parDpopProofs).toBe(2); + const parBody = new URLSearchParams((parInit!.body as string) ?? ''); + expect(parBody.get('code_challenge_method')).toBe('S256'); + expect(parBody.get('code_challenge')).toBeTruthy(); + expect(parBody.get('login_hint')).toBe('alice.test'); + + // The flow state cookie was set and round-trips through CookieManager. + const setCookie = res.headers.get('Set-Cookie') ?? ''; + expect(setCookie).toContain('atproto_flow='); + const cookieValue = setCookie.split('atproto_flow=')[1].split(';')[0]; + const flow = JSON.parse((await new CookieManager('dev-secret').decrypt(cookieValue))!); + expect(flow.did).toBe('did:plc:alicedid'); + expect(flow.tokenEndpoint).toBe('https://auth.test/token'); + expect(flow.returnUrl).toBe('/dashboard'); + expect(flow.dpopNonce).toBe('nonce-2'); + }); + + it('completes the callback: DPoP token exchange, profile, session, and credential', async () => { + let tokenDpop: string | null = null; + let tokenBody: URLSearchParams | undefined; + installAtprotoMocks({ + onToken: (init) => { + tokenDpop = new Headers(init.headers).get('DPoP'); + tokenBody = new URLSearchParams((init.body as string) ?? ''); + }, + }); + + const api = createStartupAPI(atprotoConfig); + const cm = new CookieManager('dev-secret'); + + // 1. Start the flow to obtain a valid (encrypted) flow-state cookie + its state value. + const startCtx = createExecutionContext(); + const startRes = await api.fetch(new Request('http://example.com/users/auth/atproto?handle=alice.test'), env, startCtx); + await waitOnExecutionContext(startCtx); + const flowCookie = (startRes.headers.get('Set-Cookie') ?? '').split('atproto_flow=')[1].split(';')[0]; + const flow = JSON.parse((await cm.decrypt(flowCookie))!); + + // 2. Hit the callback with the matching state + issuer and the flow cookie. + const cbCtx = createExecutionContext(); + const cbRes = await api.fetch( + new Request(`http://example.com/users/auth/atproto/callback?code=authcode&state=${flow.state}&iss=https://auth.test`, { + headers: { Cookie: `atproto_flow=${flowCookie}` }, + redirect: 'manual', + }), + env, + cbCtx, + ); + await waitOnExecutionContext(cbCtx); + + expect(cbRes.status).toBe(302); + expect(cbRes.headers.get('Location')).toBe('/'); + + // Token exchange was DPoP-bound and PKCE-verified. + expect(tokenDpop).toBeTruthy(); + expect(tokenBody!.get('grant_type')).toBe('authorization_code'); + expect(tokenBody!.get('code_verifier')).toBe(flow.verifier); + + // Session cookie set; transient flow cookie cleared. + const cookies = cbRes.headers.getSetCookie(); + expect(cookies.some((c) => c.startsWith('session_id='))).toBe(true); + expect(cookies.some((c) => c.startsWith('atproto_flow=') && c.includes('Max-Age=0'))).toBe(true); + + // Credential persisted under the atproto provider, keyed by DID. + const credentialStub = env.CREDENTIAL.get(env.CREDENTIAL.idFromName('atproto')); + const cred = await credentialStub.get('did:plc:alicedid'); + expect(cred).not.toBeNull(); + expect(cred.access_token).toBe('atproto-access-token'); + expect(cred.profile_data.name).toBe('Alice in AT'); + }); + + it('rejects a callback whose state does not match the flow cookie', async () => { + installAtprotoMocks(); + const api = createStartupAPI(atprotoConfig); + + const startCtx = createExecutionContext(); + const startRes = await api.fetch(new Request('http://example.com/users/auth/atproto?handle=alice.test'), env, startCtx); + await waitOnExecutionContext(startCtx); + const flowCookie = (startRes.headers.get('Set-Cookie') ?? '').split('atproto_flow=')[1].split(';')[0]; + + const cbCtx = createExecutionContext(); + const cbRes = await api.fetch( + new Request('http://example.com/users/auth/atproto/callback?code=authcode&state=WRONG&iss=https://auth.test', { + headers: { Cookie: `atproto_flow=${flowCookie}` }, + }), + env, + cbCtx, + ); + await waitOnExecutionContext(cbCtx); + + expect(cbRes.status).toBe(500); + expect(await cbRes.text()).toContain('State mismatch'); + }); + + it('enables atproto by presence of its config key, and opts out via enabled: false', async () => { + const { getActiveProviders } = await import('../src/handlers/utils'); + // Present (even empty) → enabled. + expect(getActiveProviders(env, { atproto: {} })).toContain('atproto'); + // Absent → disabled. + expect(getActiveProviders(env)).not.toContain('atproto'); + // Explicit opt-out → disabled. + expect(getActiveProviders(env, { atproto: { enabled: false } })).not.toContain('atproto'); + }); +}); diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts index 6ad8636..7b119ea 100644 --- a/worker-configuration.d.ts +++ b/worker-configuration.d.ts @@ -1,5 +1,5 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: 38595a6a948093665f11cb1bc4cd28e8) +// Generated by Wrangler by running `wrangler types` (hash: 21e6c5c76a7bbde8583fcbcc839c03e7) // Runtime types generated with workerd@1.20260120.0 2025-09-27 global_fetch_strictly_public declare namespace Cloudflare { interface GlobalProps { @@ -15,7 +15,6 @@ declare namespace Cloudflare { ORIGIN_URL: "https://startup-api-demo-origin.sergeychernyshev.workers.dev/"; TWITCH_CLIENT_ID: ""; TWITCH_CLIENT_SECRET: ""; - GITHUB_PROJECT_ID: string; USER: DurableObjectNamespace; ACCOUNT: DurableObjectNamespace; SYSTEM: DurableObjectNamespace; @@ -36,7 +35,6 @@ declare namespace Cloudflare { PATREON_CLIENT_ID: ""; PATREON_CLIENT_SECRET: ""; PATREON_WEBHOOK_SECRET: ""; - GITHUB_PROJECT_ID: string; USER: DurableObjectNamespace; ACCOUNT: DurableObjectNamespace; SYSTEM: DurableObjectNamespace; @@ -56,14 +54,12 @@ declare namespace Cloudflare { PATREON_CLIENT_ID: "patreon-id"; PATREON_CLIENT_SECRET: "patreon-secret"; PATREON_WEBHOOK_SECRET: "whsec-test"; - GITHUB_PROJECT_ID: string; USER: DurableObjectNamespace; ACCOUNT: DurableObjectNamespace; SYSTEM: DurableObjectNamespace; CREDENTIAL: DurableObjectNamespace; } interface Env { - GITHUB_PROJECT_ID: string; IMAGE_STORAGE: R2Bucket; ASSETS: Fetcher; ENVIRONMENT?: "preview" | "" | "test";