From 54b40e6fdff62ab977f48beb240ece4491a720b5 Mon Sep 17 00:00:00 2001 From: Jacob Zweifel Date: Wed, 12 Aug 2026 17:45:00 -0400 Subject: [PATCH] Answer certificate-validation challenges and survive plain-HTTP requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Custom-hostname DCV requests arrive over plain HTTP, and when the customer's domain is itself proxied through Cloudflare they reach the Worker instead of being answered at the edge. Two failures stacked there. The server hook built an OAuth client from the request origin on every request, and an http:// origin fails client-metadata validation — a 500 for all plain-HTTP traffic; tenant requests now skip the client entirely (every consumer of locals.oauth is an app-only path) and the hosted client mints its https identity regardless of request scheme. And nothing served the challenge: the hook now answers /.well-known/acme-challenge/ on custom-domain hosts from the custom-hostname API's validation records, fetching the per-hostname detail because the list endpoint can omit them. The certificate for mooring.malpercio.dev issued within minutes of the challenge answering going live. Renewals reuse the same path. Co-Authored-By: Claude Fable 5 --- apps/web/src/hooks.server.ts | 38 +++++++++-- apps/web/src/lib/server/hosting/cloudflare.ts | 64 ++++++++++++++++++- .../src/lib/server/hosting/hosting.test.ts | 52 ++++++++++++++- apps/web/src/lib/server/hosting/index.ts | 4 +- apps/web/src/lib/server/oauth/client.ts | 19 ++++-- apps/web/src/lib/server/oauth/oauth.test.ts | 28 ++++++++ .../src/routes/admin/hosting/+page.server.ts | 14 ++-- docs/runbooks/first-deploy.md | 6 ++ 8 files changed, 196 insertions(+), 29 deletions(-) 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 -- 2.51.2