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:
parent
0eb721d12e
commit
0ba9ea272e
|
|
@ -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<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
|
||||
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();
|
||||
})();
|
||||
|
|
|
|||
Loading…
Reference in New Issue