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)
|
// 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();
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue