diff --git a/README.md b/README.md index 7cc1563..0801a57 100644 --- a/README.md +++ b/README.md @@ -121,10 +121,12 @@ const api = createStartupAPI({ 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") + // clientName: 'My App', // advertised in the client-metadata document (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` + // useRootOAuthClientMetadata: false, // keep the client-metadata document under USERS_PATH (default: true, served at /oauth-client-metadata.json) + // customOAuthClientMetadataURL: '/oauth-client-metadata.json', // use your own self-hosted document as client_id (requires useRootOAuthClientMetadata: false) // enabled: false, // explicit opt-out (e.g. for dynamically-built config) // }, }, @@ -135,7 +137,7 @@ export const { UserDO, AccountDO, SystemDO, CredentialDO } = api; ``` 1. Enable it — set `ATPROTO_ENABLED` truthy, or include `atproto: {}` in the factory `providers` config (no client id/secret needed either way). A factory `enabled: false` forces it off. -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`. +2. Deploy over **HTTPS** with a stable hostname. The worker automatically serves its client metadata at `https:///oauth-client-metadata.json` (this URL is the OAuth `client_id`) and registers the redirect URI `https:///users/auth/atproto/callback`. See [Where the client-metadata document lives](#where-the-client-metadata-document-lives) if you'd rather not expose a root-level path. 3. That's it. When a visitor clicks **Login with your Atmosphere account**, 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: ``` @@ -147,6 +149,22 @@ export const { UserDO, AccountDO, SystemDO, CredentialDO } = api; 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. +##### Where the client-metadata document lives + +The [atproto OAuth spec](https://atproto.com/specs/oauth) lets the client-metadata document live at any `https://` URL, but `/oauth-client-metadata.json` at the domain root is the recognized convention: the reference PDS consent screen shows **just your hostname** (`example.com`) for a `client_id` at that path, and the **full metadata URL** for any other path. That is why StartupAPI serves it at the root by default — the only route it claims outside `USERS_PATH`. Three modes: + +| Mode | Config | Document served by StartupAPI at | `client_id` | +| :------------------------------- | :------------------------------------------------------------------------------------------------------------ | :---------------------------------------------------------- | :------------------------------------------------------- | +| Root (default) | `atproto: {}` | `/oauth-client-metadata.json` | `https:///oauth-client-metadata.json` | +| Under `USERS_PATH` | `atproto: { useRootOAuthClientMetadata: false }` | `/users/auth/atproto/client-metadata.json` | `https:///users/auth/atproto/client-metadata.json` | +| Self-hosted (you serve the file) | `atproto: { useRootOAuthClientMetadata: false, customOAuthClientMetadataURL: '/oauth-client-metadata.json' }` | `/users/auth/atproto/client-metadata.json` (reference copy) | your URL (root-relative, or absolute `https://`) | + +In self-hosted mode StartupAPI does not claim the root path; copy the reference document it serves under `USERS_PATH` to your URL verbatim (it already carries your `client_id`, `redirect_uris` and `scope`) and keep them in sync when you change scopes. Setting `customOAuthClientMetadataURL` without `useRootOAuthClientMetadata: false` is a configuration error. + +> **What the consent screen shows.** Authorization servers deliberately do **not** display `client_name` or a logo for clients they don't know — those fields are unverified and could impersonate another app — so Bluesky-hosted PDSes show your hostname only. A self-hosted PDS can opt in to showing your name by allowlisting your `client_id` in its `PDS_OAUTH_TRUSTED_CLIENTS` setting. +> +> **Changing the `client_id`** (switching modes, or upgrading from a version that served the document under `USERS_PATH`) invalidates existing atproto authorizations; users simply log in again. + #### 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.json b/package.json index 612807a..71bdfa5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@startup-api/cloudflare", - "version": "0.5.0", + "version": "0.6.0", "license": "Apache-2.0", "publishConfig": { "access": "public" diff --git a/src/auth/AtprotoProvider.ts b/src/auth/AtprotoProvider.ts index 7b2bf2f..6e7b78e 100644 --- a/src/auth/AtprotoProvider.ts +++ b/src/auth/AtprotoProvider.ts @@ -55,11 +55,18 @@ export function isAtprotoEnabled(options?: ProviderOptions, env?: Pick { - if (ctx.url.pathname === `${ctx.authPath}/atproto/client-metadata.json`) { + if (ctx.url.pathname === this.metadataPath) { return Response.json(this.getClientMetadata()); } return null; diff --git a/src/auth/index.ts b/src/auth/index.ts index 99b5986..dc3679b 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -9,34 +9,59 @@ import { computeRedirectBase, createProviders } from './providers'; import type { ProviderConfigs } from './providers'; import type { AuthContext, ExchangeResult, OAuthProvider } from './OAuthProvider'; -export async function handleAuth( +/** Build the per-request auth context shared by the auth router and provider auxiliary routes. */ +function createAuthContext( request: Request, env: StartupAPIEnv, url: URL, usersPath: string, cookieManager: CookieManager, - providerConfigs: ProviderConfigs = {}, - sessionTtlMs: number = DEFAULT_SESSION_TTL_MS, -): Promise { - const path = url.pathname; + sessionTtlMs: number, +): AuthContext { const origin = env.AUTH_ORIGIN && env.AUTH_ORIGIN !== '' ? env.AUTH_ORIGIN : url.origin; - - // Standardize redirectBase + // Standardize redirectBase; authPath is its path, used for internal route matching. const redirectBase = computeRedirectBase(env, origin, usersPath); - - // For internal matching, we still need authPath const authPath = new URL(redirectBase).pathname; + return { request, env, url, redirectBase, authPath, usersPath, origin, cookieManager, sessionTtlMs }; +} - // Instantiate active providers - const activeProviders = createProviders(env, redirectBase, providerConfigs); - - const ctx: AuthContext = { request, env, url, redirectBase, authPath, usersPath, origin, cookieManager, sessionTtlMs }; - - // Provider-specific auxiliary routes (e.g. the atproto client-metadata document). - for (const provider of activeProviders) { +/** + * Provider-specific auxiliary routes that are not part of the start/callback flow and may live outside + * `usersPath` — e.g. the atproto client-metadata document at the conventional `/oauth-client-metadata.json`. + * Returns null when no active provider claims the request. + */ +export async function handleProviderExtraRoutes( + request: Request, + env: StartupAPIEnv, + url: URL, + usersPath: string, + cookieManager: CookieManager, + providerConfigs: ProviderConfigs = {}, + sessionTtlMs: number = DEFAULT_SESSION_TTL_MS, +): Promise { + const ctx = createAuthContext(request, env, url, usersPath, cookieManager, sessionTtlMs); + for (const provider of createProviders(env, ctx.redirectBase, providerConfigs)) { const res = await provider.handleExtraRoute(ctx); if (res) return res; } + return null; +} + +export async function handleAuth( + request: Request, + env: StartupAPIEnv, + url: URL, + usersPath: string, + cookieManager: CookieManager, + providerConfigs: ProviderConfigs = {}, + sessionTtlMs: number = DEFAULT_SESSION_TTL_MS, +): Promise { + const path = url.pathname; + const ctx = createAuthContext(request, env, url, usersPath, cookieManager, sessionTtlMs); + const { authPath } = ctx; + + // Instantiate active providers + const activeProviders = createProviders(env, ctx.redirectBase, providerConfigs); // Handle Auth Start for (const provider of activeProviders) { diff --git a/src/createStartupAPI.ts b/src/createStartupAPI.ts index e991148..caf3974 100644 --- a/src/createStartupAPI.ts +++ b/src/createStartupAPI.ts @@ -1,4 +1,4 @@ -import { handleAuth } from './auth/index'; +import { handleAuth, handleProviderExtraRoutes } from './auth/index'; import { injectPowerStrip } from './PowerStrip'; import { UserDO } from './storage/UserDO'; import { AccountDO } from './storage/AccountDO'; @@ -183,6 +183,12 @@ export function createStartupAPI(config: StartupAPIConfig = {}) { const cookieManager = new CookieManager(env.SESSION_SECRET); + // Provider auxiliary documents that may live outside USERS_PATH (e.g. the atproto client-metadata + // document at the conventional /oauth-client-metadata.json root path). Checked first so a provider + // can claim its exact path; everything else falls through unchanged. + const extraRouteRes = await handleProviderExtraRoutes(request, env, url, usersPath, cookieManager, providerConfigs, sessionTtlMs); + if (extraRouteRes) return extraRouteRes; + // SSR Routes const usersPathNormalized = usersPath.endsWith('/') ? usersPath : usersPath + '/'; if (url.pathname.startsWith(usersPathNormalized)) { diff --git a/src/schemas/config.ts b/src/schemas/config.ts index fa12668..6cec444 100644 --- a/src/schemas/config.ts +++ b/src/schemas/config.ts @@ -39,7 +39,7 @@ export function durationToMs(d: Duration): number { /** Periodic re-sync of entitlements via a scheduled() handler. `true`/`{ schedule }` on, off by default. */ const EntitlementCronSchema = z.union([z.boolean(), z.object({ schedule: z.string().optional() })]); -export const ProviderOptionsSchema = z.object({ +export const ProviderOptionsObjectSchema = z.object({ /** Force-enable/disable the provider. Default: enabled iff its credentials are present in env. */ enabled: z.boolean().optional(), /** Extra OAuth scopes to request, on top of the provider's required base scopes. */ @@ -52,6 +52,21 @@ export const ProviderOptionsSchema = z.object({ plcUrl: z.string().optional(), /** atproto only: override the DNS-over-HTTPS resolver used for handle resolution. */ dohUrl: z.string().optional(), + /** + * atproto only: serve the OAuth client-metadata document at the domain root + * (`/oauth-client-metadata.json`) and use that URL as the `client_id`. ON by default — atproto + * authorization servers recognize this conventional path and show just your hostname on the consent + * screen instead of the full metadata URL. Set `false` to keep the document (and the `client_id`) + * under `USERS_PATH`, at `…/auth/atproto/client-metadata.json`. + */ + useRootOAuthClientMetadata: z.boolean().optional(), + /** + * atproto only: use a client-metadata document you host yourself as the `client_id`, instead of the + * one StartupAPI serves. A root-relative path (`/oauth-client-metadata.json`, resolved against the + * auth origin) or an absolute `https://` URL. Requires `useRootOAuthClientMetadata: false`; StartupAPI + * then keeps serving a reference copy under `USERS_PATH` for you to mirror at this URL. + */ + customOAuthClientMetadataURL: z.string().min(1).optional(), /** * Lazily re-check entitlements on the request hot path when the cache is older than this duration. * ON by default for entitlement providers — default 1 day for Patreon, 15 min otherwise. Pass a @@ -64,6 +79,29 @@ export const ProviderOptionsSchema = z.object({ entitlementCron: EntitlementCronSchema.optional(), }); +/** Provider options with the cross-field checks the object schema cannot express. */ +export const ProviderOptionsSchema = ProviderOptionsObjectSchema.superRefine((options, ctx) => { + const custom = options.customOAuthClientMetadataURL?.trim(); + if (custom === undefined) return; + // A self-hosted document and the root document would compete for the same URL; make the choice explicit. + if (options.useRootOAuthClientMetadata !== false) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['customOAuthClientMetadataURL'], + message: 'customOAuthClientMetadataURL requires useRootOAuthClientMetadata: false', + }); + } + // atproto client ids must be https:// URLs (http://localhost is the spec's dev-only exception). + const isRootRelative = custom.startsWith('/') && !custom.startsWith('//'); + if (!isRootRelative && !custom.startsWith('https://') && !custom.startsWith('http://localhost')) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['customOAuthClientMetadataURL'], + message: 'customOAuthClientMetadataURL must be a root-relative path (e.g. "/oauth-client-metadata.json") or an https:// URL', + }); + } +}); + export const SessionConfigSchema = z.object({ /** * Login session lifetime as a Duration (default 30 days). The session is a rolling window — renewed diff --git a/test/atproto.spec.ts b/test/atproto.spec.ts index 9b48756..68e995f 100644 --- a/test/atproto.spec.ts +++ b/test/atproto.spec.ts @@ -74,20 +74,109 @@ function installAtprotoMocks(opts: { onPar?: (init: RequestInit) => void; onToke }) as typeof fetch; } +/** Fetch a URL through a configured instance and return the response. */ +async function get(config: Parameters[0], url: string): Promise { + const api = createStartupAPI(config); + const ctx = createExecutionContext(); + const res = await api.fetch(new Request(url), env, ctx); + await waitOnExecutionContext(ctx); + return res; +} + 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); + describe('client-metadata document', () => { + it('is served at the conventional domain root by default, which is also the client_id', async () => { + const res = await get(atprotoConfig, 'http://example.com/oauth-client-metadata.json'); + + expect(res.status).toBe(200); + const meta = (await res.json()) as Record; + expect(meta.client_id).toBe('http://example.com/oauth-client-metadata.json'); + expect(meta.client_uri).toBe('http://example.com'); + 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'); + + // Only one URL can be the client id (the document must be fetched from it), so no alias is kept + // under the auth path in root mode. + const old = await get(atprotoConfig, 'http://example.com/users/auth/atproto/client-metadata.json'); + expect(old.status).toBe(404); + }); - 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('is not served at the root when atproto is disabled', async () => { + // Without the provider, the root path falls through to the regular (asset/origin) handling. + const res = await get({}, 'http://example.com/oauth-client-metadata.json'); + expect(res.status).not.toBe(200); + }); + + it('stays under USERS_PATH with useRootOAuthClientMetadata: false', async () => { + const config = { providers: { atproto: { useRootOAuthClientMetadata: false } } }; + const res = await get(config, 'http://example.com/users/auth/atproto/client-metadata.json'); + + 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']); + + const root = await get(config, 'http://example.com/oauth-client-metadata.json'); + expect(root.status).not.toBe(200); + }); + + it('uses a self-hosted document as client_id and serves a reference copy under USERS_PATH', async () => { + const config = { + providers: { atproto: { useRootOAuthClientMetadata: false, customOAuthClientMetadataURL: '/oauth-client-metadata.json' } }, + }; + // StartupAPI does not claim the root path — the app owner serves their own file there. + const root = await get(config, 'http://example.com/oauth-client-metadata.json'); + expect(root.status).not.toBe(200); + + // The reference copy advertises the owner's URL as client_id so it can be mirrored verbatim. + const ref = await get(config, 'http://example.com/users/auth/atproto/client-metadata.json'); + expect(ref.status).toBe(200); + const meta = (await ref.json()) as Record; + expect(meta.client_id).toBe('http://example.com/oauth-client-metadata.json'); + expect(meta.redirect_uris).toEqual(['http://example.com/users/auth/atproto/callback']); + + // The custom client_id is what the authorization server is told (PAR + authorize redirect). + let parBody: URLSearchParams | undefined; + installAtprotoMocks({ onPar: (init) => (parBody = new URLSearchParams((init.body as string) ?? '')) }); + const res = await get(config, 'http://example.com/users/auth/atproto?handle=alice.test'); + expect(res.status).toBe(302); + expect(new URL(res.headers.get('Location')!).searchParams.get('client_id')).toBe('http://example.com/oauth-client-metadata.json'); + expect(parBody!.get('client_id')).toBe('http://example.com/oauth-client-metadata.json'); + }); + + it('accepts an absolute https:// self-hosted document URL', async () => { + const config = { + providers: { + atproto: { + useRootOAuthClientMetadata: false, + customOAuthClientMetadataURL: 'https://www.example.com/oauth-client-metadata.json', + }, + }, + }; + const ref = await get(config, 'http://example.com/users/auth/atproto/client-metadata.json'); + const meta = (await ref.json()) as Record; + expect(meta.client_id).toBe('https://www.example.com/oauth-client-metadata.json'); + expect(meta.client_uri).toBe('https://www.example.com'); + }); + + it('rejects customOAuthClientMetadataURL unless useRootOAuthClientMetadata is false', () => { + expect(() => createStartupAPI({ providers: { atproto: { customOAuthClientMetadataURL: '/oauth-client-metadata.json' } } })).toThrow( + /useRootOAuthClientMetadata: false/, + ); + expect(() => + createStartupAPI({ providers: { atproto: { useRootOAuthClientMetadata: true, customOAuthClientMetadataURL: '/x.json' } } }), + ).toThrow(/useRootOAuthClientMetadata: false/); + }); + + it('rejects a customOAuthClientMetadataURL that is neither root-relative nor https://', () => { + for (const bad of ['oauth-client-metadata.json', '//cdn.example.com/x.json', 'http://example.com/x.json']) { + expect(() => + createStartupAPI({ providers: { atproto: { useRootOAuthClientMetadata: false, customOAuthClientMetadataURL: bad } } }), + ).toThrow(/root-relative path/); + } + }); }); it('shows a handle-entry form when no identifier is provided', async () => { @@ -138,7 +227,7 @@ describe('atproto provider', () => { 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'); + expect(location.searchParams.get('client_id')).toBe('http://example.com/oauth-client-metadata.json'); // PAR carried a DPoP proof on each attempt and the PKCE challenge. expect(parDpopProofs).toBe(2);