BillTracker/routes/authOidc.cts

127 lines
5.3 KiB
TypeScript
Raw Permalink Normal View History

import type { Req, Res, Next } from '../types/http';
('use strict');
2026-05-03 19:51:57 -05:00
/**
* OIDC / authentik authentication routes.
*
* GET /api/auth/oidc/login initiates authorization-code + PKCE flow
* GET /api/auth/oidc/callback handles redirect from the identity provider
*
* Returns 501 if OIDC is not configured or not enabled by admin.
* Local auth is unaffected. Tokens are never logged or forwarded to the frontend.
*/
const express = require('express');
const { log } = require('../utils/logger.cts');
const router = express.Router();
2026-05-03 19:51:57 -05:00
const {
createSession,
cookieOpts,
COOKIE_NAME,
recordLogin,
} = require('../services/authService.cts');
2026-05-03 19:51:57 -05:00
const {
getOidcConfig,
isOidcLoginActive,
createLoginState,
consumeLoginState,
buildAuthorizationUrl,
exchangeAndVerifyTokens,
findOrProvisionUser,
} = require('../services/oidcService.cts');
2026-05-03 19:51:57 -05:00
const NOT_ACTIVE = { error: 'OIDC authentication is not configured or not enabled on this server' };
// ── GET /api/auth/oidc/login ──────────────────────────────────────────────────
// Validates that OIDC is active, generates PKCE + nonce + state, stores in DB,
// then redirects the user to the identity provider's authorization endpoint.
router.get('/login', async (req: Req, res: Res) => {
2026-05-03 19:51:57 -05:00
if (!isOidcLoginActive()) return res.status(501).json(NOT_ACTIVE);
const config = getOidcConfig();
if (!config) return res.status(501).json(NOT_ACTIVE);
try {
const redirectTo = typeof req.query.redirect_to === 'string' ? req.query.redirect_to : null;
const state = createLoginState(redirectTo);
const authUrl = await buildAuthorizationUrl(config, state);
2026-05-03 19:51:57 -05:00
res.redirect(authUrl);
} catch (err: any) {
2026-05-31 16:08:24 -05:00
const msg = err.message || String(err);
log.error('[oidc] Login initiation error:', msg || '(no message)');
2026-05-31 16:08:24 -05:00
if (err.errors) {
err.errors.forEach((e: any, i: number) => log.error(` [${i}]`, e.message || String(e)));
2026-05-31 16:08:24 -05:00
}
if (err.cause) log.error(' cause:', err.cause.message || String(err.cause));
2026-05-03 19:51:57 -05:00
res.status(502).json({ error: 'Failed to reach the identity provider. Please try again.' });
}
});
// ── GET /api/auth/oidc/callback ───────────────────────────────────────────────
// Receives code + state from the identity provider after user authentication.
//
// Full validation via openid-client@5:
// • State matches saved state (replay prevention)
// • PKCE code_verifier / code_challenge
// • JWT signature via JWKS (cryptographic verification)
// • Issuer, audience, expiry, nonce claims
//
// On success: creates a local session, sets cookie, redirects to frontend.
// On any failure: redirects with a safe oidc_error query parameter.
// Tokens are never logged or forwarded.
router.get('/callback', async (req: Req, res: Res) => {
2026-05-03 19:51:57 -05:00
if (!isOidcLoginActive()) return res.redirect('/?oidc_error=not_configured');
const config = getOidcConfig();
if (!config) return res.redirect('/?oidc_error=not_configured');
const { code, state: stateId, error } = req.query;
// Provider signalled an authorization error — log code only, not description (may have PII)
if (error) {
log.error('[oidc] Provider error on callback:', String(error).slice(0, 40));
2026-05-03 19:51:57 -05:00
return res.redirect('/?oidc_error=authorization_failed');
}
if (!code || !stateId || typeof code !== 'string' || typeof stateId !== 'string') {
return res.redirect('/?oidc_error=invalid_callback');
}
// Consume the one-time state — validates expiry and prevents replay
const savedState = consumeLoginState(stateId);
if (!savedState) {
return res.redirect('/?oidc_error=invalid_or_expired_state');
}
try {
fix: four review-confirmed regressions in auth + OIDC flows All found by the multi-angle /code-review over the branch and verified against the installed libraries: - 401 discriminator: the session-expiry redirect keyed off a path prefix (!/auth/*) and falsely logged users out on /profile/change-password with a wrong current password. requireAuth's no-session 401 now carries the distinct AUTH_REQUIRED code — the only signal api.ts dispatches auth:expired on; wrong-credential 401s keep AUTH_ERROR on any path. Test updated to pin the code-based contract on both directions. - OIDC RFC 9207: exchangeAndVerifyTokens rebuilt the callback URL with only code+state, dropping the iss parameter openid-client v6 validates when the provider advertises support (authentik does) — every OIDC login would fail post-upgrade. The full callback query is now forwarded. - OIDC http issuers: v6 enforces HTTPS on discovery/endpoints (v5 didn't) — breaking internal http:// providers on Docker networks. OIDC_ALLOW_INSECURE_HTTP=true opts back in (documented in .env.example). - 2FA downgrade: authService's webauthn fallback caught ANY challenge error and silently downgraded to password-only login; now only the stale-flag 'No registered WebAuthn credentials' case falls through, everything else rethrows. - Burned-challenge retry: the server consumes the single-use 2FA challenge before verifying (anti-replay), so after a server rejection a retry can only ever return 'Challenge expired'. LoginPage now resets to sign-in on server failure (WebAuthn AND the pre-existing TOTP trap); a browser-prompt cancel keeps retry (token still live). Also lazy-loads @simplewebauthn/browser out of the entry bundle, matching ProfilePage. Server 252/252, client 52/52, OIDC smoke 44/44, probe 33/33, e2e 27. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 22:57:07 -05:00
// Full token exchange + cryptographic ID token verification via openid-client@6.
// Verifies: state, PKCE, JWT signature (JWKS), issuer, audience, expiry, nonce,
// and the RFC 9207 `iss` response parameter — which is why the FULL callback
// query is forwarded, not just code/state. Throws on any validation failure.
// Tokens are never logged.
const claims = await exchangeAndVerifyTokens(config, code, stateId, savedState, req.query);
2026-05-03 19:51:57 -05:00
// Map verified claims to a local user account
const user = await findOrProvisionUser(claims, config);
// Create a local session — same mechanism as local login
const session = await createSession(user.id);
if (!session) throw new Error('Failed to create local session after OIDC login');
recordLogin(user.id, req.ip, req.get('user-agent'), session.sessionId);
2026-05-03 19:51:57 -05:00
res.cookie(COOKIE_NAME, session.sessionId, cookieOpts(req));
res.redirect(savedState.redirect_to || '/');
} catch (err: any) {
2026-05-03 19:51:57 -05:00
// Log message only — never log tokens, codes, or ID token contents
2026-05-31 16:08:24 -05:00
const msg = err.message || String(err);
log.error('[oidc] Callback error:', msg || '(no message)');
2026-05-31 16:08:24 -05:00
if (err.errors) {
err.errors.forEach((e: any, i: number) => log.error(` [${i}]`, e.message || String(e)));
2026-05-31 16:08:24 -05:00
}
if (err.cause) log.error(' cause:', err.cause.message || String(err.cause));
2026-05-03 19:51:57 -05:00
const errCode = err.status === 403 ? 'access_denied' : 'authentication_failed';
res.redirect(`/?oidc_error=${errCode}`);
}
});
module.exports = router;