oidc.js (15905B)
1 import crypto from 'node:crypto'; 2 import { app } from '../app.js'; 3 import { HttpError } from '../lib/http/http-error.js'; 4 import { 5 authenticateClient, 6 getClient, 7 postLogoutRedirectAllowed, 8 redirectUriAllowed, 9 } from '../lib/oidc/clients.js'; 10 import { 11 canonicalIssuer, 12 endpointBase, 13 endpointUrls, 14 resolveOidcContext, 15 } from '../lib/oidc/context.js'; 16 import { getJwks, signToken, verifyToken } from '../lib/oidc/keys.js'; 17 import { 18 clearAuthRequestCookie, 19 clearAuthRequestSelection, 20 completeAuthRequest, 21 createAuthRequest, 22 createAuthorizationCode, 23 deleteAuthRequest, 24 getAuthRequest, 25 purgeExpired, 26 readAuthRequestId, 27 setAuthRequestCookie, 28 } from '../lib/oidc/requests.js'; 29 import { canUseClient } from '../lib/policy.js'; 30 import { 31 getSystemDatabase, 32 openRealmDatabase, 33 realmMetadata, 34 } from '../lib/realm.js'; 35 import { 36 authenticateRequest, 37 bindSessionRealm, 38 clearSessionCookie, 39 destroySession, 40 listSessionAccounts, 41 requestToken, 42 } from '../lib/session.js'; 43 import { 44 findUserById, 45 getMembership, 46 getPrimaryEmail, 47 getUserRoles, 48 } from '../lib/users.js'; 49 50 function oidcError(status, error, description, headers = {}) { 51 return new app.HttpResponse( 52 status, 53 { error, error_description: description }, 54 { 'cache-control': 'no-store', ...headers } 55 ); 56 } 57 58 function redirect(res, location) { 59 return new app.HttpResponse(302, undefined, { location }); 60 } 61 62 function withQuery(url, params) { 63 const target = new URL(url); 64 for (const [key, value] of Object.entries(params)) { 65 if (value !== undefined && value !== null && value !== '') { 66 target.searchParams.set(key, String(value)); 67 } 68 } 69 return target.toString(); 70 } 71 72 function bearerToken(req) { 73 const header = String(req.headers.authorization || ''); 74 return header.startsWith('Bearer ') ? header.slice(7) : null; 75 } 76 77 function accessDenied(res, redirectUri, state) { 78 return redirect( 79 res, 80 withQuery(redirectUri, { 81 error: 'access_denied', 82 error_description: 'The user is not allowed to use this client', 83 state, 84 }) 85 ); 86 } 87 88 async function discovery(context, client) { 89 const realm = await realmMetadata(context.realm); 90 const publicUrl = app.config.server.public_url; 91 const urls = endpointUrls(context); 92 const branding = Object.keys(client.branding || {}).length > 0 ? client.branding : realm.branding; 93 const scopes = String(client.scopes || 'openid profile email') 94 .split(/\s+/) 95 .filter(Boolean); 96 97 return new app.HttpOk( 98 { 99 issuer: canonicalIssuer(publicUrl, context.realm.slug), 100 client_id: client.client_id, 101 client_name: client.name, 102 realm: context.realm.slug, 103 display_name: client.name, 104 branding, 105 access_mode: client.access_mode, 106 virtual: Boolean(client.virtual), 107 allow_session_duration_choice: realm.allow_session_duration_choice, 108 authorization_endpoint: urls.authorize, 109 token_endpoint: urls.token, 110 userinfo_endpoint: urls.userinfo, 111 jwks_uri: urls.jwks, 112 end_session_endpoint: urls.logout, 113 response_types_supported: ['code'], 114 grant_types_supported: ['authorization_code'], 115 subject_types_supported: ['public'], 116 id_token_signing_alg_values_supported: ['ES256'], 117 scopes_supported: scopes, 118 token_endpoint_auth_methods_supported: [client.token_endpoint_auth_method || 'none'], 119 code_challenge_methods_supported: ['S256'], 120 claims_supported: [ 121 'iss', 122 'sub', 123 'aud', 124 'exp', 125 'iat', 126 'auth_time', 127 'nonce', 128 'name', 129 'preferred_username', 130 'email', 131 'email_verified', 132 'roles', 133 ], 134 require_pkce: Boolean(client.require_pkce), 135 }, 136 { 'cache-control': 'no-cache' } 137 ); 138 } 139 140 async function authorize(context, client, req, res) { 141 const url = new URL(req.url, context.origin); 142 const params = url.searchParams; 143 const redirectUri = params.get('redirect_uri'); 144 const state = params.get('state'); 145 const scope = params.get('scope') || 'openid'; 146 const clientId = params.get('client_id') || context.clientId; 147 const virtualCallbacks = [ 148 `${endpointBase(context)}/callback`, 149 `${context.origin}/app/${client.client_id}/callback`, 150 ]; 151 152 if (!redirectUri) { 153 return oidcError(400, 'invalid_request', 'redirect_uri is required'); 154 } 155 if (!redirectUriAllowed(client, redirectUri, virtualCallbacks)) { 156 return oidcError(400, 'invalid_request', 'redirect_uri is not registered'); 157 } 158 159 const fail = (error, description) => 160 redirect(res, withQuery(redirectUri, { error, error_description: description, state })); 161 162 if (params.get('response_type') !== 'code') { 163 return fail('unsupported_response_type', 'Only response_type=code is supported'); 164 } 165 if (clientId !== client.client_id) { 166 return fail('invalid_request', 'client_id does not match the requested client'); 167 } 168 if (!scope.split(/\s+/).includes('openid')) { 169 return fail('invalid_scope', 'The openid scope is required'); 170 } 171 172 const codeChallenge = params.get('code_challenge'); 173 const codeChallengeMethod = params.get('code_challenge_method'); 174 if (client.require_pkce && (!codeChallenge || codeChallengeMethod !== 'S256')) { 175 return fail('invalid_request', 'PKCE with S256 is required'); 176 } 177 178 const db = await openRealmDatabase(context.realm); 179 const systemDb = getSystemDatabase(); 180 await purgeExpired(db); 181 182 const pendingId = readAuthRequestId(req, context.realm); 183 let pending = await getAuthRequest(db, pendingId); 184 if ( 185 pending && 186 (pending.client_id !== client.client_id || 187 pending.redirect_uri !== redirectUri || 188 pending.completed_at) 189 ) { 190 await deleteAuthRequest(db, pending.id); 191 pending = null; 192 } 193 194 const auth = await authenticateRequest({ 195 systemDb, 196 realmDb: db, 197 req, 198 res, 199 context, 200 }); 201 202 const allowed = auth 203 ? canUseClient({ 204 user: auth.user, 205 membership: auth.membership, 206 roles: auth.roles, 207 client, 208 realm: context.realm, 209 }) 210 : false; 211 212 if (auth && !allowed) { 213 if (pending) { 214 await deleteAuthRequest(db, pending.id); 215 clearAuthRequestCookie(res, req, context.realm); 216 } 217 return accessDenied(res, redirectUri, state); 218 } 219 220 if (!pending) { 221 pending = { 222 id: await createAuthRequest(db, { 223 clientId: client.client_id, 224 redirectUri, 225 scope, 226 state, 227 nonce: params.get('nonce'), 228 codeChallenge, 229 codeChallengeMethod, 230 }), 231 }; 232 233 if (!auth) { 234 setAuthRequestCookie(res, req, context.realm, pending.id); 235 return redirect(res, endpointUrls(context).login); 236 } 237 } 238 239 if (!auth) { 240 setAuthRequestCookie(res, req, context.realm, pending.id); 241 return redirect(res, endpointUrls(context).login); 242 } 243 244 const accounts = await listSessionAccounts(systemDb, auth.group); 245 const selected = Boolean( 246 pending.user_id && 247 pending.session_id && 248 pending.session_id === auth.session.id && 249 pending.user_id === auth.user.id 250 ); 251 if ((pending.user_id || pending.session_id) && !selected) { 252 await clearAuthRequestSelection(db, pending.id); 253 } 254 if (!selected && accounts.length >= 2) { 255 setAuthRequestCookie(res, req, context.realm, pending.id); 256 return redirect(res, endpointUrls(context).login); 257 } 258 259 await bindSessionRealm(systemDb, auth.session.id, context.realm.id); 260 await completeAuthRequest(db, pending.id, { 261 userId: auth.user.id, 262 sessionId: auth.session.id, 263 authTime: auth.session.auth_time, 264 }); 265 266 const request = await getAuthRequest(db, pending.id); 267 const code = await createAuthorizationCode(db, request); 268 await deleteAuthRequest(db, pending.id); 269 clearAuthRequestCookie(res, req, context.realm); 270 271 return redirect(res, withQuery(redirectUri, { code, state })); 272 } 273 274 function verifyPkce(codeVerifier, challenge) { 275 if (!codeVerifier || !challenge) return false; 276 const computed = crypto.createHash('sha256').update(codeVerifier).digest('base64url'); 277 const left = Buffer.from(computed); 278 const right = Buffer.from(challenge); 279 return left.length === right.length && crypto.timingSafeEqual(left, right); 280 } 281 282 async function token(context, client, req) { 283 if (req.method !== 'POST') { 284 return oidcError(405, 'invalid_request', 'POST is required'); 285 } 286 287 const body = req.body || {}; 288 if (!authenticateClient(req, client, body)) { 289 return oidcError(401, 'invalid_client', 'Client authentication failed', { 290 'www-authenticate': 'Basic realm="oidc"', 291 }); 292 } 293 if (body.grant_type !== 'authorization_code') { 294 return oidcError(400, 'unsupported_grant_type', 'Only authorization_code is supported'); 295 } 296 297 const db = await openRealmDatabase(context.realm); 298 const systemDb = getSystemDatabase(); 299 const code = await db.get( 300 `SELECT id, auth_request_id, client_id, user_id, redirect_uri, scope, nonce, 301 code_challenge, code_challenge_method, session_id, auth_time, 302 expires_at, consumed_at 303 FROM authorization_codes 304 WHERE id = {id}`, 305 { id: crypto.createHash('sha256').update(String(body.code || '')).digest('hex') } 306 ); 307 308 if ( 309 !code || 310 code.consumed_at || 311 code.expires_at <= Date.now() || 312 code.client_id !== client.client_id 313 ) { 314 return oidcError(400, 'invalid_grant', 'The authorization code is invalid'); 315 } 316 if (body.redirect_uri !== code.redirect_uri) { 317 return oidcError(400, 'invalid_grant', 'redirect_uri does not match'); 318 } 319 if (code.code_challenge) { 320 if ( 321 code.code_challenge_method !== 'S256' || 322 !verifyPkce(body.code_verifier, code.code_challenge) 323 ) { 324 return oidcError(400, 'invalid_grant', 'PKCE verification failed'); 325 } 326 } else if (client.require_pkce) { 327 return oidcError(400, 'invalid_grant', 'PKCE is required'); 328 } 329 330 await db.run('UPDATE authorization_codes SET consumed_at = {now} WHERE id = {id}', { 331 now: Date.now(), 332 id: code.id, 333 }); 334 335 const user = await findUserById(systemDb, code.user_id); 336 if (!user || user.status !== 'active') { 337 return oidcError(400, 'invalid_grant', 'The user is not available'); 338 } 339 340 const membership = await getMembership(db, user.id); 341 const roles = await getUserRoles(db, user.id); 342 if ( 343 !canUseClient({ 344 user, 345 membership, 346 roles, 347 client, 348 realm: context.realm, 349 }) 350 ) { 351 return oidcError(400, 'invalid_grant', 'The user no longer has access to this client'); 352 } 353 354 const email = await getPrimaryEmail(systemDb, user.id); 355 const issuer = canonicalIssuer(app.config.server.public_url, context.realm.slug); 356 const audience = client.client_id; 357 const expiresIn = 300; 358 359 const idClaims = { 360 sub: user.id, 361 email: email?.email, 362 email_verified: Boolean(email?.verified), 363 name: user.display_name || user.username, 364 preferred_username: user.username, 365 roles, 366 auth_time: code.auth_time, 367 sid: code.session_id || undefined, 368 nonce: code.nonce || undefined, 369 }; 370 371 const accessClaims = { 372 sub: user.id, 373 client_id: client.client_id, 374 scope: code.scope, 375 roles, 376 sid: code.session_id || undefined, 377 }; 378 379 const idToken = await signToken(db, idClaims, { issuer, audience, expiresIn }); 380 const accessToken = await signToken(db, accessClaims, { issuer, audience, expiresIn }); 381 382 return new app.HttpOk( 383 { 384 access_token: accessToken, 385 token_type: 'Bearer', 386 expires_in: expiresIn, 387 id_token: idToken, 388 scope: code.scope, 389 }, 390 { 'cache-control': 'no-store' } 391 ); 392 } 393 394 async function userinfo(context, client, req) { 395 const tokenValue = bearerToken(req); 396 if (!tokenValue) { 397 return oidcError(401, 'invalid_token', 'A bearer token is required', { 398 'www-authenticate': 'Bearer', 399 }); 400 } 401 402 const db = await openRealmDatabase(context.realm); 403 const issuer = canonicalIssuer(app.config.server.public_url, context.realm.slug); 404 let payload; 405 try { 406 payload = await verifyToken(db, tokenValue, { 407 issuer, 408 audience: client.client_id, 409 }); 410 } catch { 411 return oidcError(401, 'invalid_token', 'The access token is invalid', { 412 'www-authenticate': 'Bearer error="invalid_token"', 413 }); 414 } 415 416 if (payload.client_id !== client.client_id || !payload.sub) { 417 return oidcError(401, 'invalid_token', 'The access token does not belong to this client', { 418 'www-authenticate': 'Bearer error="invalid_token"', 419 }); 420 } 421 422 const systemDb = getSystemDatabase(); 423 const user = await findUserById(systemDb, payload.sub); 424 if (!user || user.status !== 'active') { 425 return oidcError(401, 'invalid_token', 'The user is not available'); 426 } 427 428 const email = await getPrimaryEmail(systemDb, user.id); 429 const roles = await getUserRoles(db, user.id); 430 431 return new app.HttpOk({ 432 sub: user.id, 433 name: user.display_name || user.username, 434 preferred_username: user.username, 435 email: email?.email, 436 email_verified: Boolean(email?.verified), 437 roles, 438 }); 439 } 440 441 async function logout(context, client, req, res) { 442 const url = new URL(req.url, context.origin); 443 const postLogoutUri = url.searchParams.get('post_logout_redirect_uri'); 444 const state = url.searchParams.get('state'); 445 const virtualCallbacks = [ 446 `${endpointBase(context)}/callback`, 447 `${context.origin}/app/${client.client_id}/callback`, 448 ]; 449 450 if (!postLogoutRedirectAllowed(client, postLogoutUri, virtualCallbacks)) { 451 return oidcError(400, 'invalid_request', 'post_logout_redirect_uri is not registered'); 452 } 453 454 const db = await openRealmDatabase(context.realm); 455 const idTokenHint = url.searchParams.get('id_token_hint'); 456 if (idTokenHint) { 457 try { 458 await verifyToken(db, idTokenHint, { 459 issuer: canonicalIssuer(app.config.server.public_url, context.realm.slug), 460 audience: client.client_id, 461 }); 462 } catch { 463 return oidcError(400, 'invalid_request', 'id_token_hint is invalid'); 464 } 465 } 466 467 const result = await destroySession(getSystemDatabase(), requestToken(req)); 468 if (result.groupDeleted) clearSessionCookie(res, req); 469 clearAuthRequestCookie(res, req, context.realm); 470 471 if (postLogoutUri) { 472 return redirect(res, withQuery(postLogoutUri, { state })); 473 } 474 return new app.HttpOk({ ok: true }); 475 } 476 477 export default { 478 mount: '/oidc', 479 name: 'oidc', 480 async handler(req, res, next) { 481 try { 482 const context = await resolveOidcContext(req, { 483 publicUrl: app.config.server.public_url, 484 }); 485 if (context.action === 'login' || context.action === 'callback') { 486 next(); 487 return; 488 } 489 490 const db = await openRealmDatabase(context.realm); 491 const client = await getClient(db, context.clientId); 492 if (!client || !client.enabled) { 493 throw new HttpError(404, 'unknown_client', 'Unknown client'); 494 } 495 496 let result; 497 switch (context.action) { 498 case 'openid-configuration': 499 result = await discovery(context, client); 500 break; 501 case 'jwks': 502 result = new app.HttpOk(await getJwks(db)); 503 break; 504 case 'authorize': 505 result = await authorize(context, client, req, res); 506 break; 507 case 'token': 508 result = await token(context, client, req); 509 break; 510 case 'userinfo': 511 result = await userinfo(context, client, req); 512 break; 513 case 'logout': 514 result = await logout(context, client, req, res); 515 break; 516 default: 517 throw new HttpError(404, 'not_found', 'Not found'); 518 } 519 520 if (result) result.send(res); 521 } catch (e) { 522 if (res.headersSent) { 523 res.end(); 524 return; 525 } 526 if (e instanceof app.HttpError) { 527 e.toResponse().send(res); 528 return; 529 } 530 console.error(e); 531 new app.HttpError(500, 'internal_error', 'Internal server error').toResponse().send(res); 532 } 533 }, 534 };