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;
lastUsed?: number;
transports?: string[];
rpId?: string;
}
export interface StoredChallenge {
@ -86,14 +87,15 @@ export async function storeCredential(cred: StoredCredential): Promise<void> {
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'}
)
`;
}

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);
});
// ============================================================================
// 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,