feat(oidc): switch from HS256 to RS256 token signing

- Generate or load RSA keypair for OIDC token signing (OIDC_RSA_PRIVATE_KEY env)
- Add /oidc/jwks endpoint exposing public key in JWK format
- Update discovery document with jwks_uri and RS256 algorithm
- Sign ID tokens and access tokens with RS256 private key
- Verify access tokens with RS256 public key in userinfo
- Fix OIDC_ISSUER default to auth.rspace.online (was auth.ridentity.online)
- Add POST handler for /oidc/userinfo (RFC compliance)
- Add error logging to userinfo endpoint for debugging

Fixes Cloudflare Access OIDC integration which requires asymmetric
token signing via JWKS for ID token verification.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-12 11:49:36 -04:00
parent 0eb721d12e
commit 0ba9ea272e
1 changed files with 113 additions and 9 deletions

View File

@ -6642,7 +6642,102 @@ function oidcAcceptPage(token: string): string {
// OIDC PROVIDER (Authorization Code Flow) // OIDC PROVIDER (Authorization Code Flow)
// ============================================================================ // ============================================================================
const OIDC_ISSUER = process.env.OIDC_ISSUER || 'https://auth.ridentity.online'; const OIDC_ISSUER = process.env.OIDC_ISSUER || 'https://auth.rspace.online';
// --- RS256 Key Management ---
// Load RSA private key from env (PEM), or auto-generate an ephemeral keypair.
// For production, set OIDC_RSA_PRIVATE_KEY with a PEM-encoded PKCS8 private key.
let oidcPrivateKey: CryptoKey;
let oidcPublicKey: CryptoKey;
let oidcJwk: JsonWebKey & { kid: string; use: string; alg: string; kty: string };
async function initOidcKeys() {
const pemEnv = process.env.OIDC_RSA_PRIVATE_KEY;
if (pemEnv) {
// Import PEM private key
const pemBody = pemEnv
.replace(/-----BEGIN PRIVATE KEY-----/, '')
.replace(/-----END PRIVATE KEY-----/, '')
.replace(/\s/g, '');
const binaryDer = Uint8Array.from(atob(pemBody), c => c.charCodeAt(0));
oidcPrivateKey = await crypto.subtle.importKey(
'pkcs8', binaryDer,
{ name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
true, ['sign']
);
// Derive public key by exporting/importing
const jwk = await crypto.subtle.exportKey('jwk', oidcPrivateKey);
// Remove private components for public key
const pubJwk = { kty: jwk.kty!, n: jwk.n!, e: jwk.e! };
oidcPublicKey = await crypto.subtle.importKey(
'jwk', pubJwk,
{ name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
true, ['verify']
);
oidcJwk = { ...pubJwk, kid: 'oidc-1', use: 'sig', alg: 'RS256', kty: 'RSA' };
console.log('[oidc] Loaded RSA private key from OIDC_RSA_PRIVATE_KEY');
} else {
// Auto-generate ephemeral keypair (not persistent across restarts)
console.warn('[oidc] WARNING: No OIDC_RSA_PRIVATE_KEY set, generating ephemeral RSA keypair');
const keyPair = await crypto.subtle.generateKey(
{ name: 'RSASSA-PKCS1-v1_5', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' },
true, ['sign', 'verify']
);
oidcPrivateKey = keyPair.privateKey;
oidcPublicKey = keyPair.publicKey;
const pubJwk = await crypto.subtle.exportKey('jwk', oidcPublicKey);
oidcJwk = { kty: pubJwk.kty!, n: pubJwk.n!, e: pubJwk.e!, kid: 'oidc-1', use: 'sig', alg: 'RS256' };
}
}
// Sign a JWT payload with RS256
async function signRS256(payload: Record<string, unknown>): Promise<string> {
const header = { alg: 'RS256', typ: 'JWT', kid: 'oidc-1' };
const encode = (obj: unknown) => btoa(JSON.stringify(obj)).replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
const headerB64 = encode(header);
const payloadB64 = encode(payload);
const signingInput = `${headerB64}.${payloadB64}`;
const signature = await crypto.subtle.sign(
'RSASSA-PKCS1-v1_5',
oidcPrivateKey,
new TextEncoder().encode(signingInput)
);
const sigB64 = btoa(String.fromCharCode(...new Uint8Array(signature)))
.replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
return `${signingInput}.${sigB64}`;
}
// Verify a JWT with RS256, returns payload or throws
async function verifyRS256(token: string): Promise<Record<string, unknown>> {
const parts = token.split('.');
if (parts.length !== 3) throw new Error('Invalid JWT format');
const signingInput = `${parts[0]}.${parts[1]}`;
// Decode signature
const sigB64 = parts[2].replace(/-/g, '+').replace(/_/g, '/');
const padded = sigB64 + '='.repeat((4 - sigB64.length % 4) % 4);
const signature = Uint8Array.from(atob(padded), c => c.charCodeAt(0));
const valid = await crypto.subtle.verify(
'RSASSA-PKCS1-v1_5',
oidcPublicKey,
signature,
new TextEncoder().encode(signingInput)
);
if (!valid) throw new Error('Invalid signature');
// Decode payload
const payloadB64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
const payloadPadded = payloadB64 + '='.repeat((4 - payloadB64.length % 4) % 4);
const payload = JSON.parse(atob(payloadPadded));
// Check expiry
if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) {
throw new Error('Token expired');
}
return payload;
}
// JWKS endpoint — public key for RS256 token verification
app.get('/oidc/jwks', (c) => {
return c.json({ keys: [oidcJwk] });
});
// Discovery document // Discovery document
app.get('/.well-known/openid-configuration', (c) => { app.get('/.well-known/openid-configuration', (c) => {
@ -6651,9 +6746,10 @@ app.get('/.well-known/openid-configuration', (c) => {
authorization_endpoint: `${OIDC_ISSUER}/oidc/authorize`, authorization_endpoint: `${OIDC_ISSUER}/oidc/authorize`,
token_endpoint: `${OIDC_ISSUER}/oidc/token`, token_endpoint: `${OIDC_ISSUER}/oidc/token`,
userinfo_endpoint: `${OIDC_ISSUER}/oidc/userinfo`, userinfo_endpoint: `${OIDC_ISSUER}/oidc/userinfo`,
jwks_uri: `${OIDC_ISSUER}/oidc/jwks`,
response_types_supported: ['code'], response_types_supported: ['code'],
subject_types_supported: ['public'], subject_types_supported: ['public'],
id_token_signing_alg_values_supported: ['HS256'], id_token_signing_alg_values_supported: ['RS256'],
scopes_supported: ['openid', 'profile', 'email'], scopes_supported: ['openid', 'profile', 'email'],
claims_supported: ['sub', 'email', 'name', 'preferred_username'], claims_supported: ['sub', 'email', 'name', 'preferred_username'],
grant_types_supported: ['authorization_code'], grant_types_supported: ['authorization_code'],
@ -6926,7 +7022,7 @@ app.post('/oidc/token', async (c) => {
preferred_username: user.username, preferred_username: user.username,
}; };
const idToken = await sign(idTokenPayload, CONFIG.jwtSecret); const idToken = await signRS256(idTokenPayload);
// Access token is also a JWT with same claims (used by userinfo) // Access token is also a JWT with same claims (used by userinfo)
const accessTokenPayload = { const accessTokenPayload = {
@ -6937,7 +7033,7 @@ app.post('/oidc/token', async (c) => {
exp: now + 3600, exp: now + 3600,
scope: authCode.scope, scope: authCode.scope,
}; };
const accessToken = await sign(accessTokenPayload, CONFIG.jwtSecret); const accessToken = await signRS256(accessTokenPayload);
return c.json({ return c.json({
access_token: accessToken, access_token: accessToken,
@ -6947,23 +7043,26 @@ app.post('/oidc/token', async (c) => {
}); });
}); });
// UserInfo endpoint // UserInfo endpoint (GET and POST per RFC 7662)
app.get('/oidc/userinfo', async (c) => { async function handleUserInfo(c: any) {
const authHeader = c.req.header('Authorization'); const authHeader = c.req.header('Authorization');
if (!authHeader?.startsWith('Bearer ')) { if (!authHeader?.startsWith('Bearer ')) {
console.error('[oidc] userinfo: missing or invalid Authorization header');
return c.json({ error: 'invalid_token' }, 401); return c.json({ error: 'invalid_token' }, 401);
} }
const token = authHeader.slice(7); const token = authHeader.slice(7);
let payload: any; let payload: any;
try { try {
payload = await verify(token, CONFIG.jwtSecret, 'HS256'); payload = await verifyRS256(token);
} catch { } catch (err) {
console.error('[oidc] userinfo: token verification failed:', (err as Error).message);
return c.json({ error: 'invalid_token' }, 401); return c.json({ error: 'invalid_token' }, 401);
} }
const user = await getUserById(payload.sub); const user = await getUserById(payload.sub);
if (!user) { if (!user) {
console.error('[oidc] userinfo: user not found for sub:', payload.sub);
return c.json({ error: 'invalid_token' }, 401); return c.json({ error: 'invalid_token' }, 401);
} }
@ -6973,7 +7072,9 @@ app.get('/oidc/userinfo', async (c) => {
name: user.display_name || user.username, name: user.display_name || user.username,
preferred_username: user.username, preferred_username: user.username,
}); });
}); }
app.get('/oidc/userinfo', handleUserInfo);
app.post('/oidc/userinfo', handleUserInfo);
// Authorize page HTML — embeds passkey login, then posts back to complete authorization // Authorize page HTML — embeds passkey login, then posts back to complete authorization
function oidcAuthorizePage(appName: string, clientId: string, redirectUri: string, scope: string, state: string): string { function oidcAuthorizePage(appName: string, clientId: string, redirectUri: string, scope: string, state: string): string {
@ -9287,6 +9388,9 @@ app.get('/api/users/directory', async (c) => {
console.error('EncryptID: Failed to seed OIDC clients:', (err as Error).message); console.error('EncryptID: Failed to seed OIDC clients:', (err as Error).message);
} }
// Initialize OIDC RS256 keys
await initOidcKeys();
// Start trust engine background job (recomputes scores every 5 min) // Start trust engine background job (recomputes scores every 5 min)
startTrustEngine(); startTrustEngine();
})(); })();