Dynamic RP ID: use caller's domain for WebAuthn passkeys

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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-19 01:46:15 +00:00
parent 048171131b
commit 7103366047
2 changed files with 45 additions and 5 deletions

View File

@ -37,6 +37,7 @@ export interface StoredCredential {
createdAt: number; createdAt: number;
lastUsed?: number; lastUsed?: number;
transports?: string[]; transports?: string[];
rpId?: string;
} }
export interface StoredChallenge { export interface StoredChallenge {
@ -86,14 +87,15 @@ export async function storeCredential(cred: StoredCredential): Promise<void> {
await createUser(cred.userId, cred.username); await createUser(cred.userId, cred.username);
await sql` 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 ( VALUES (
${cred.credentialId}, ${cred.credentialId},
${cred.userId}, ${cred.userId},
${cred.publicKey}, ${cred.publicKey},
${cred.counter}, ${cred.counter},
${cred.transports || null}, ${cred.transports || null},
${new Date(cred.createdAt)} ${new Date(cred.createdAt)},
${cred.rpId || 'rspace.online'}
) )
`; `;
} }

View File

@ -222,6 +222,38 @@ app.get('/health', async (c) => {
return c.json({ status, service: 'encryptid', database: dbHealthy, timestamp: Date.now() }, dbHealthy ? 200 : 503); 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 // REGISTRATION ENDPOINTS
// ============================================================================ // ============================================================================
@ -252,11 +284,12 @@ app.post('/api/register/start', async (c) => {
}; };
await storeChallenge(challengeRecord); await storeChallenge(challengeRecord);
// Build registration options // Build registration options — use the caller's domain as RP ID
const rpId = resolveRpId(c);
const options = { const options = {
challenge, challenge,
rp: { rp: {
id: CONFIG.rpId, id: rpId,
name: CONFIG.rpName, name: CONFIG.rpName,
}, },
user: { user: {
@ -312,6 +345,8 @@ app.post('/api/register/complete', async (c) => {
await setUserEmail(userId, email); await setUserEmail(userId, email);
} }
// Resolve the RP ID from the caller's origin
const rpId = resolveRpId(c);
const storedCredential: StoredCredential = { const storedCredential: StoredCredential = {
credentialId: credential.credentialId, credentialId: credential.credentialId,
publicKey: credential.publicKey, publicKey: credential.publicKey,
@ -320,6 +355,7 @@ app.post('/api/register/complete', async (c) => {
counter: 0, counter: 0,
createdAt: Date.now(), createdAt: Date.now(),
transports: credential.transports, transports: credential.transports,
rpId,
}; };
await storeCredential(storedCredential); 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 = { const options = {
challenge, challenge,
rpId: CONFIG.rpId, rpId,
userVerification: 'required', userVerification: 'required',
timeout: 60000, timeout: 60000,
allowCredentials, allowCredentials,