From 7103366047b65a96e2c27805b9caf326c824425c Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Thu, 19 Feb 2026 01:46:15 +0000 Subject: [PATCH] Dynamic RP ID: use caller's domain for WebAuthn passkeys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of hardcoding rpId to "rspace.online" (which requires Related Origins support), derive the RP ID from the request's Origin header. Each r* app (rmaps.online, rnotes.online, etc.) now gets its own RP ID matching its domain, so passkeys work natively without browser support for Related Origin Requests. - Added resolveRpId() helper that maps Origin → hostname for allowed origins - Registration creates passkeys with the caller's domain as RP ID - Authentication uses the caller's domain as RP ID - Added rp_id column to credentials table for per-credential RP ID tracking - rspace.online subdomains still use rspace.online as shared RP ID Co-Authored-By: Claude Opus 4.6 --- src/encryptid/db.ts | 6 ++++-- src/encryptid/server.ts | 44 ++++++++++++++++++++++++++++++++++++++--- 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/src/encryptid/db.ts b/src/encryptid/db.ts index 9bcd473..fd3997e 100644 --- a/src/encryptid/db.ts +++ b/src/encryptid/db.ts @@ -37,6 +37,7 @@ export interface StoredCredential { createdAt: number; lastUsed?: number; transports?: string[]; + rpId?: string; } export interface StoredChallenge { @@ -86,14 +87,15 @@ export async function storeCredential(cred: StoredCredential): Promise { await createUser(cred.userId, cred.username); await sql` - INSERT INTO credentials (credential_id, user_id, public_key, counter, transports, created_at) + INSERT INTO credentials (credential_id, user_id, public_key, counter, transports, created_at, rp_id) VALUES ( ${cred.credentialId}, ${cred.userId}, ${cred.publicKey}, ${cred.counter}, ${cred.transports || null}, - ${new Date(cred.createdAt)} + ${new Date(cred.createdAt)}, + ${cred.rpId || 'rspace.online'} ) `; } diff --git a/src/encryptid/server.ts b/src/encryptid/server.ts index 532fb46..a133119 100644 --- a/src/encryptid/server.ts +++ b/src/encryptid/server.ts @@ -222,6 +222,38 @@ app.get('/health', async (c) => { return c.json({ status, service: 'encryptid', database: dbHealthy, timestamp: Date.now() }, dbHealthy ? 200 : 503); }); +// ============================================================================ +// RP ID RESOLUTION +// ============================================================================ + +/** + * Derive the RP ID from the request's Origin header. + * If the origin is an allowed r* domain, use that domain as the RP ID. + * Falls back to CONFIG.rpId (rspace.online) for rspace.online origins and unknown. + */ +function resolveRpId(c: any): string { + const origin = c.req.header('origin') || c.req.header('referer') || ''; + try { + const url = new URL(origin); + const hostname = url.hostname; + // Check if this origin is in our allowed list + const isAllowed = CONFIG.allowedOrigins.some(o => { + try { return new URL(o).hostname === hostname; } catch { return false; } + }); + if (isAllowed && hostname !== 'localhost') { + // For *.rspace.online subdomains, use rspace.online + if (hostname.endsWith('.rspace.online') || hostname === 'rspace.online') { + return 'rspace.online'; + } + // For other allowed origins, use their domain as RP ID + return hostname; + } + } catch { + // Invalid origin, fall back to default + } + return CONFIG.rpId; +} + // ============================================================================ // REGISTRATION ENDPOINTS // ============================================================================ @@ -252,11 +284,12 @@ app.post('/api/register/start', async (c) => { }; await storeChallenge(challengeRecord); - // Build registration options + // Build registration options — use the caller's domain as RP ID + const rpId = resolveRpId(c); const options = { challenge, rp: { - id: CONFIG.rpId, + id: rpId, name: CONFIG.rpName, }, user: { @@ -312,6 +345,8 @@ app.post('/api/register/complete', async (c) => { await setUserEmail(userId, email); } + // Resolve the RP ID from the caller's origin + const rpId = resolveRpId(c); const storedCredential: StoredCredential = { credentialId: credential.credentialId, publicKey: credential.publicKey, @@ -320,6 +355,7 @@ app.post('/api/register/complete', async (c) => { counter: 0, createdAt: Date.now(), transports: credential.transports, + rpId, }; await storeCredential(storedCredential); @@ -375,9 +411,11 @@ app.post('/api/auth/start', async (c) => { } } + // Use the caller's domain as RP ID + const rpId = resolveRpId(c); const options = { challenge, - rpId: CONFIG.rpId, + rpId, userVerification: 'required', timeout: 60000, allowCredentials,