auth

Main authentication module for my projects
git clone git://git.finwo.net/app/auth
Log | Files | Refs

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 };