From 0ba9ea272eff0e7addbd3b0ec2d9e5e042bcb651 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Sun, 12 Apr 2026 11:49:36 -0400 Subject: [PATCH] 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 --- src/encryptid/server.ts | 122 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 113 insertions(+), 9 deletions(-) diff --git a/src/encryptid/server.ts b/src/encryptid/server.ts index b6f08a22..d9ef69b8 100644 --- a/src/encryptid/server.ts +++ b/src/encryptid/server.ts @@ -6642,7 +6642,102 @@ function oidcAcceptPage(token: string): string { // 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): Promise { + 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> { + 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 app.get('/.well-known/openid-configuration', (c) => { @@ -6651,9 +6746,10 @@ app.get('/.well-known/openid-configuration', (c) => { authorization_endpoint: `${OIDC_ISSUER}/oidc/authorize`, token_endpoint: `${OIDC_ISSUER}/oidc/token`, userinfo_endpoint: `${OIDC_ISSUER}/oidc/userinfo`, + jwks_uri: `${OIDC_ISSUER}/oidc/jwks`, response_types_supported: ['code'], subject_types_supported: ['public'], - id_token_signing_alg_values_supported: ['HS256'], + id_token_signing_alg_values_supported: ['RS256'], scopes_supported: ['openid', 'profile', 'email'], claims_supported: ['sub', 'email', 'name', 'preferred_username'], grant_types_supported: ['authorization_code'], @@ -6926,7 +7022,7 @@ app.post('/oidc/token', async (c) => { 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) const accessTokenPayload = { @@ -6937,7 +7033,7 @@ app.post('/oidc/token', async (c) => { exp: now + 3600, scope: authCode.scope, }; - const accessToken = await sign(accessTokenPayload, CONFIG.jwtSecret); + const accessToken = await signRS256(accessTokenPayload); return c.json({ access_token: accessToken, @@ -6947,23 +7043,26 @@ app.post('/oidc/token', async (c) => { }); }); -// UserInfo endpoint -app.get('/oidc/userinfo', async (c) => { +// UserInfo endpoint (GET and POST per RFC 7662) +async function handleUserInfo(c: any) { const authHeader = c.req.header('Authorization'); if (!authHeader?.startsWith('Bearer ')) { + console.error('[oidc] userinfo: missing or invalid Authorization header'); return c.json({ error: 'invalid_token' }, 401); } const token = authHeader.slice(7); let payload: any; try { - payload = await verify(token, CONFIG.jwtSecret, 'HS256'); - } catch { + payload = await verifyRS256(token); + } catch (err) { + console.error('[oidc] userinfo: token verification failed:', (err as Error).message); return c.json({ error: 'invalid_token' }, 401); } const user = await getUserById(payload.sub); if (!user) { + console.error('[oidc] userinfo: user not found for sub:', payload.sub); return c.json({ error: 'invalid_token' }, 401); } @@ -6973,7 +7072,9 @@ app.get('/oidc/userinfo', async (c) => { name: user.display_name || 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 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); } + // Initialize OIDC RS256 keys + await initOidcKeys(); + // Start trust engine background job (recomputes scores every 5 min) startTrustEngine(); })();