BillTracker/routes/authOidc.js

107 lines
4.4 KiB
JavaScript

'use strict';
/**
* 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 router = express.Router();
const { createSession, cookieOpts, COOKIE_NAME } = require('../services/authService');
const {
getOidcConfig,
isOidcLoginActive,
createLoginState,
consumeLoginState,
buildAuthorizationUrl,
exchangeAndVerifyTokens,
findOrProvisionUser,
} = require('../services/oidcService');
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, res) => {
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);
res.redirect(authUrl);
} catch (err) {
console.error('[oidc] Login initiation error:', err.message);
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, res) => {
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) {
console.error('[oidc] Provider error on callback:', String(error).slice(0, 40));
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 {
// Full token exchange + cryptographic ID token verification via openid-client@5.
// Verifies: state, PKCE, JWT signature (JWKS), issuer, audience, expiry, nonce.
// Throws on any validation failure. Tokens are never logged.
const claims = await exchangeAndVerifyTokens(config, code, stateId, savedState);
// 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');
res.cookie(COOKIE_NAME, session.sessionId, cookieOpts(req));
res.redirect(savedState.redirect_to || '/');
} catch (err) {
// Log message only — never log tokens, codes, or ID token contents
console.error('[oidc] Callback error:', err.message);
const errCode = err.status === 403 ? 'access_denied' : 'authentication_failed';
res.redirect(`/?oidc_error=${errCode}`);
}
});
module.exports = router;