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;
|
||||
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'}
|
||||
)
|
||||
`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue