diff --git a/apps/web/src/hooks.server.ts b/apps/web/src/hooks.server.ts index fe9465c..054b027 100644 --- a/apps/web/src/hooks.server.ts +++ b/apps/web/src/hooks.server.ts @@ -1,7 +1,14 @@ import { error, type Handle } from '@sveltejs/kit'; import type { NodeOAuthClient } from '@atproto/oauth-client-node'; import { createOAuthClient } from '$lib/server/oauth/client'; -import { classifyHost, didForDomain, didForSlug, isAppOnlyPath } from '$lib/server/hosting'; +import { + acmeChallengeBody, + classifyHost, + cloudflareApiFromEnv, + didForDomain, + didForSlug, + isAppOnlyPath +} from '$lib/server/hosting'; // One client per isolate. Keyed by origin so a stale cache can't survive a // hostname change within an isolate's lifetime (unlikely, but cheap to guard). @@ -10,11 +17,6 @@ let cached: { origin: string; client: Promise } | undefined; export const handle: Handle = async ({ event, resolve }) => { const env = event.platform?.env; if (env) { - if (cached?.origin !== event.url.origin) { - cached = { origin: event.url.origin, client: createOAuthClient(env, event.url.origin) }; - } - event.locals.oauth = await cached.client; - // Without APP_HOST every host serves the app, which is what dev wants. if (env.APP_HOST) { const host = classifyHost(event.url.host, env.APP_HOST); @@ -24,6 +26,21 @@ export const handle: Handle = async ({ event, resolve }) => { if (!did) error(404, 'No site is moored at this address.'); event.locals.tenant = { did }; } else if (host.kind === 'custom-domain') { + // Certificate-validation challenges arrive over plain HTTP and, when + // the customer's zone is itself proxied through Cloudflare, reach the + // Worker rather than being answered at the edge. + if (event.url.pathname.startsWith('/.well-known/acme-challenge/')) { + const api = cloudflareApiFromEnv(env); + const body = api + ? await acmeChallengeBody(api, host.domain, event.url.pathname).catch((err) => { + console.error('acme challenge lookup failed', err); + return undefined; + }) + : undefined; + if (body) { + return new Response(body, { headers: { 'content-type': 'text/plain' } }); + } + } // An unknown host (workers.dev, a domain mid-setup) serves the app; // Cloudflare for SaaS only routes hostnames we created. const did = await didForDomain(env.DB, host.domain); @@ -33,6 +50,15 @@ export const handle: Handle = async ({ event, resolve }) => { error(404, 'Not found.'); } } + + // Tenant requests never touch OAuth — every consumer of locals.oauth is + // an app-only path, 404ed above. + if (!event.locals.tenant) { + if (cached?.origin !== event.url.origin) { + cached = { origin: event.url.origin, client: createOAuthClient(env, event.url.origin) }; + } + event.locals.oauth = await cached.client; + } } return resolve(event); }; diff --git a/apps/web/src/lib/server/hosting/cloudflare.ts b/apps/web/src/lib/server/hosting/cloudflare.ts index dc6feed..de9965d 100644 --- a/apps/web/src/lib/server/hosting/cloudflare.ts +++ b/apps/web/src/lib/server/hosting/cloudflare.ts @@ -13,6 +13,11 @@ export interface CloudflareApi { fetch?: typeof fetch; } +export interface ValidationRecord { + httpUrl?: string; + httpBody?: string; +} + export interface CustomHostname { id: string; hostname: string; @@ -20,6 +25,8 @@ export interface CustomHostname { status?: string; /** Certificate state, e.g. "pending_validation", "active". */ sslStatus?: string; + /** Pending certificate-validation challenges, empty once validated. */ + validationRecords: ValidationRecord[]; } class CloudflareApiError extends Error {} @@ -50,17 +57,31 @@ async function request( return data?.result; } +function toValidationRecords(value: unknown): ValidationRecord[] { + if (!Array.isArray(value)) return []; + const records: ValidationRecord[] = []; + for (const entry of value) { + if (entry === null || typeof entry !== 'object') continue; + const { http_url, http_body } = entry as Record; + records.push({ + httpUrl: typeof http_url === 'string' ? http_url : undefined, + httpBody: typeof http_body === 'string' ? http_body : undefined + }); + } + return records; +} + function toCustomHostname(value: unknown): CustomHostname | undefined { if (value === null || typeof value !== 'object') return undefined; const { id, hostname, status, ssl } = value as Record; if (typeof id !== 'string' || typeof hostname !== 'string') return undefined; - const sslStatus = - ssl !== null && typeof ssl === 'object' ? (ssl as Record).status : undefined; + const sslRecord = ssl !== null && typeof ssl === 'object' ? (ssl as Record) : {}; return { id, hostname, status: typeof status === 'string' ? status : undefined, - sslStatus: typeof sslStatus === 'string' ? sslStatus : undefined + sslStatus: typeof sslRecord.status === 'string' ? sslRecord.status : undefined, + validationRecords: toValidationRecords(sslRecord.validation_records) }; } @@ -104,6 +125,43 @@ export async function provisionCustomHostname( return parsed; } +/** + * The body to serve for a certificate-validation challenge, or undefined when + * the path matches no pending challenge for the domain. Cloudflare answers + * these at the edge for most hostnames, but when the customer's zone is + * itself proxied through Cloudflare the request reaches the Worker instead — + * so the Worker answers from the same source of truth. Covers renewals too. + */ +export async function acmeChallengeBody( + api: CloudflareApi, + domain: string, + pathname: string +): Promise { + const listed = await customHostnameStatus(api, domain); + if (!listed) return undefined; + // The list endpoint can omit validation records; the per-hostname endpoint + // carries the full ssl detail. + const detail = toCustomHostname(await request(api, 'GET', `/custom_hostnames/${listed.id}`)); + const hostname = detail ?? listed; + for (const record of hostname.validationRecords) { + if (!record.httpUrl || !record.httpBody) continue; + let recordPath: string; + try { + recordPath = new URL(record.httpUrl).pathname; + } catch { + continue; + } + if (recordPath === pathname) return record.httpBody; + } + return undefined; +} + +/** Undefined when custom-hostname provisioning isn't configured (self-host). */ +export function cloudflareApiFromEnv(env: App.Platform['env']): CloudflareApi | undefined { + if (!env.CLOUDFLARE_ZONE_ID || !env.CLOUDFLARE_API_TOKEN) return undefined; + return { zoneId: env.CLOUDFLARE_ZONE_ID, apiToken: env.CLOUDFLARE_API_TOKEN }; +} + /** Remove a domain's custom hostname; a no-op when none exists. */ export async function deprovisionCustomHostname( api: CloudflareApi, diff --git a/apps/web/src/lib/server/hosting/hosting.test.ts b/apps/web/src/lib/server/hosting/hosting.test.ts index 4fb7a00..263754a 100644 --- a/apps/web/src/lib/server/hosting/hosting.test.ts +++ b/apps/web/src/lib/server/hosting/hosting.test.ts @@ -5,6 +5,7 @@ import { normalizeDomain, verificationRecordName } from './domains'; import { classifyHost, isAppOnlyPath } from './hosts'; import { lookupTxt, verifyDomainOwnership } from './dns'; import { + acmeChallengeBody, customHostnameStatus, deprovisionCustomHostname, provisionCustomHostname, @@ -173,7 +174,12 @@ describe('dns over https', () => { describe('custom hostname provisioning', () => { interface FakeZone { - hostnames: { id: string; hostname: string; status?: string; ssl?: { status?: string } }[]; + hostnames: { + id: string; + hostname: string; + status?: string; + ssl?: { status?: string; validation_records?: { http_url?: string; http_body?: string }[] }; + }[]; posts: string[]; deletes: string[]; } @@ -186,6 +192,16 @@ describe('custom hostname provisioning', () => { return Response.json({ success: false, errors: [{ message: 'bad path' }] }, { status: 404 }); } if (method === 'GET') { + const idPart = url.pathname.split('/custom_hostnames/')[1]; + if (idPart) { + const found = zone.hostnames.find((h) => h.id === idPart); + return found + ? Response.json({ success: true, result: found }) + : Response.json( + { success: false, errors: [{ message: 'not found' }] }, + { status: 404 } + ); + } const wanted = url.searchParams.get('hostname'); return Response.json({ success: true, @@ -224,11 +240,43 @@ describe('custom hostname provisioning', () => { id: 'ch-9', hostname: 'example.com', status: 'active', - sslStatus: 'active' + sslStatus: 'active', + validationRecords: [] }); expect(await customHostnameStatus(cfApi(zone), 'other.com')).toBeUndefined(); }); + it('answers a pending certificate-validation challenge by path', async () => { + const zone: FakeZone = { + hostnames: [ + { + id: 'ch-1', + hostname: 'example.com', + ssl: { + status: 'pending_validation', + validation_records: [ + { + http_url: 'http://example.com/.well-known/acme-challenge/token-a', + http_body: 'token-a.key-auth' + } + ] + } + } + ], + posts: [], + deletes: [] + }; + expect( + await acmeChallengeBody(cfApi(zone), 'example.com', '/.well-known/acme-challenge/token-a') + ).toBe('token-a.key-auth'); + expect( + await acmeChallengeBody(cfApi(zone), 'example.com', '/.well-known/acme-challenge/other') + ).toBeUndefined(); + expect( + await acmeChallengeBody(cfApi(zone), 'absent.com', '/.well-known/acme-challenge/token-a') + ).toBeUndefined(); + }); + it('provisions a new hostname once and returns the existing one after', async () => { const zone: FakeZone = { hostnames: [], posts: [], deletes: [] }; const first = await provisionCustomHostname(cfApi(zone), 'example.com'); diff --git a/apps/web/src/lib/server/hosting/index.ts b/apps/web/src/lib/server/hosting/index.ts index b7d98b0..27099bf 100644 --- a/apps/web/src/lib/server/hosting/index.ts +++ b/apps/web/src/lib/server/hosting/index.ts @@ -4,11 +4,13 @@ export { classifyHost, isAppOnlyPath } from './hosts'; export type { HostClass } from './hosts'; export { lookupTxt, verifyDomainOwnership } from './dns'; export { + acmeChallengeBody, + cloudflareApiFromEnv, customHostnameStatus, deprovisionCustomHostname, provisionCustomHostname } from './cloudflare'; -export type { CloudflareApi, CustomHostname } from './cloudflare'; +export type { CloudflareApi, CustomHostname, ValidationRecord } from './cloudflare'; export { addCustomDomain, claimSubdomain, diff --git a/apps/web/src/lib/server/oauth/client.ts b/apps/web/src/lib/server/oauth/client.ts index 91fcda7..08edc03 100644 --- a/apps/web/src/lib/server/oauth/client.ts +++ b/apps/web/src/lib/server/oauth/client.ts @@ -23,21 +23,23 @@ export async function createOAuthClient( env: App.Platform['env'], origin: string ): Promise { - const redirectOrigin = new URL(origin); - if (redirectOrigin.hostname === 'localhost') redirectOrigin.hostname = '127.0.0.1'; - const redirectUri = new URL('/oauth/callback', redirectOrigin).href; - let clientMetadata: NodeOAuthClientOptions['clientMetadata']; let keyset: NodeOAuthClientOptions['keyset']; if (env.OAUTH_PRIVATE_KEY_JWK) { + // The public client identity is always https — a request that arrived + // over plain HTTP must not mint an http:// client_id, which the client + // metadata validation rejects. + const publicOrigin = new URL(origin); + publicOrigin.protocol = 'https:'; + const redirectUri = new URL('/oauth/callback', publicOrigin).href; keyset = [ await JoseKey.fromImportable(importablePrivateKeyJwk(env.OAUTH_PRIVATE_KEY_JWK), 'mooring-1') ]; clientMetadata = { - client_id: new URL('/client-metadata.json', origin).href, + client_id: new URL('/client-metadata.json', publicOrigin).href, client_name: 'Mooring', - client_uri: origin, + client_uri: publicOrigin.origin, redirect_uris: [redirectUri], grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], @@ -46,9 +48,12 @@ export async function createOAuthClient( token_endpoint_auth_method: 'private_key_jwt', token_endpoint_auth_signing_alg: 'ES256', dpop_bound_access_tokens: true, - jwks_uri: new URL('/jwks.json', origin).href + jwks_uri: new URL('/jwks.json', publicOrigin).href }; } else { + const redirectOrigin = new URL(origin); + if (redirectOrigin.hostname === 'localhost') redirectOrigin.hostname = '127.0.0.1'; + const redirectUri = new URL('/oauth/callback', redirectOrigin).href; clientMetadata = { client_id: `http://localhost?${new URLSearchParams({ redirect_uri: redirectUri, diff --git a/apps/web/src/lib/server/oauth/oauth.test.ts b/apps/web/src/lib/server/oauth/oauth.test.ts index 7515e5b..bb910e8 100644 --- a/apps/web/src/lib/server/oauth/oauth.test.ts +++ b/apps/web/src/lib/server/oauth/oauth.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from 'vitest'; +import type { D1Database } from '@cloudflare/workers-types'; import { JoseKey } from '@atproto/jwk-jose'; +import { createOAuthClient } from './client'; import { importablePrivateKeyJwk } from './private-key'; async function webCryptoPrivateJwk(): Promise { @@ -47,3 +49,29 @@ describe('importablePrivateKeyJwk', () => { expect(() => importablePrivateKeyJwk('"a string"')).toThrow('OAUTH_PRIVATE_KEY_JWK'); }); }); + +describe('createOAuthClient', () => { + it('mints an https client identity even from a plain-http origin', async () => { + const exported = await webCryptoPrivateJwk(); + const client = await createOAuthClient( + { DB: {} as D1Database, OAUTH_PRIVATE_KEY_JWK: JSON.stringify(exported) }, + 'http://mooring.example' + ); + expect(client.clientMetadata.client_id).toBe('https://mooring.example/client-metadata.json'); + expect(client.clientMetadata.jwks_uri).toBe('https://mooring.example/jwks.json'); + expect(client.clientMetadata.redirect_uris[0]).toBe( + 'https://mooring.example/oauth/callback' + ); + }); + + it('keeps the loopback client on 127.0.0.1 without a key', async () => { + const client = await createOAuthClient( + { DB: {} as D1Database }, + 'http://localhost:5173' + ); + expect(client.clientMetadata.client_id).toContain('http://localhost'); + expect(client.clientMetadata.redirect_uris[0]).toBe( + 'http://127.0.0.1:5173/oauth/callback' + ); + }); +}); diff --git a/apps/web/src/routes/admin/hosting/+page.server.ts b/apps/web/src/routes/admin/hosting/+page.server.ts index c13826e..9184713 100644 --- a/apps/web/src/routes/admin/hosting/+page.server.ts +++ b/apps/web/src/routes/admin/hosting/+page.server.ts @@ -5,6 +5,7 @@ import { InvalidInput } from '$lib/server/mooring'; import { addCustomDomain, claimSubdomain, + cloudflareApiFromEnv, customDomainsForDid, customHostnameStatus, deprovisionCustomHostname, @@ -17,20 +18,13 @@ import { subdomainForDid, verificationRecordName, verifyDomainOwnership, - type CloudflareApi, type CustomHostname } from '$lib/server/hosting'; -/** Undefined when custom-hostname provisioning isn't configured (self-host). */ -function cloudflareApi(env: App.Platform['env']): CloudflareApi | undefined { - if (!env.CLOUDFLARE_ZONE_ID || !env.CLOUDFLARE_API_TOKEN) return undefined; - return { zoneId: env.CLOUDFLARE_ZONE_ID, apiToken: env.CLOUDFLARE_API_TOKEN }; -} - export const load: PageServerLoad = async (event) => { const admin = await requireSession(event); const db = event.platform!.env.DB; - const api = cloudflareApi(event.platform!.env); + const api = cloudflareApiFromEnv(event.platform!.env); const [slug, domains] = await Promise.all([ subdomainForDid(db, admin.did), customDomainsForDid(db, admin.did) @@ -123,7 +117,7 @@ export const actions: Actions = { return fail(404, { message: 'That domain is not on your account.' }); } - const api = cloudflareApi(event.platform!.env); + const api = cloudflareApiFromEnv(event.platform!.env); if (api) { try { await deprovisionCustomHostname(api, raw); @@ -162,7 +156,7 @@ export const actions: Actions = { await markDomainVerified(db, raw, admin.did); - const api = cloudflareApi(event.platform!.env); + const api = cloudflareApiFromEnv(event.platform!.env); if (api) { try { await provisionCustomHostname(api, raw); diff --git a/docs/runbooks/first-deploy.md b/docs/runbooks/first-deploy.md index 57527d9..cedde59 100644 --- a/docs/runbooks/first-deploy.md +++ b/docs/runbooks/first-deploy.md @@ -85,6 +85,12 @@ Never in the repo, a commit, or a PR — set them with `wrangler secret put`. `/admin/hosting`, set the two DNS records it shows (TXT for ownership, CNAME to point traffic), verify — the page should show TLS provisioning start and reach “TLS active”, and the domain should serve the site. + (Passed 2026-08-12: mooring.malpercio.dev. Note for domains that are + themselves proxied through Cloudflare (orange-to-orange): certificate + validation requests reach the Worker instead of being answered at + Cloudflare's edge, so the Worker serves + `/.well-known/acme-challenge/…` from the custom-hostname API — issuance + and renewals both. Requests for these arrive over plain HTTP.) ## 5. Afterwards