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:
parent
048171131b
commit
7103366047
|
|
@ -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'}
|
||||||
)
|
)
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue