BillTracker/services/oidcService.js

510 lines
19 KiB
JavaScript
Raw Permalink Normal View History

2026-05-03 19:51:57 -05:00
'use strict';
/**
* OIDC / authentik integration service.
*
* Uses openid-client@5 for full ID token validation including JWKS-backed
* signature verification, issuer, audience, expiry, and nonce checks.
*
* Disabled by default. OIDC settings are resolved from the database first,
* then environment variables when DB values are blank, then safe defaults.
* Local username/password login continues to work regardless of this setting.
*
* authentik setup notes
*
* 1. In authentik, create an OAuth2/OpenID Provider:
* - Name: bill-tracker (or any)
* - Client type: Confidential
* - Authorization flow: explicit / implicit as appropriate
* - Redirect URI: must exactly match the Admin panel Redirect URI
* (e.g. https://bills.example.com/api/auth/oidc/callback)
*
* 2. Scopes: openid, email, profile, groups
* (groups scope is needed for group role mapping)
*
* 3. Set the group claim: authentik sends groups as a flat list in the
* "groups" claim. Set the Admin panel admin group to the authentik group
* name that should receive the admin role in bill-tracker.
*
* 4. Configure OIDC in Admin Authentication Methods:
* Issuer URL, Client ID, Client Secret, Redirect URI, Scopes,
* Auto-provision, and Admin group.
*
* 5. Optional bootstrap fallback environment variables:
* OIDC_ENABLED=true
* OIDC_ISSUER_URL=https://auth.example.com/application/o/bill-tracker/
* OIDC_CLIENT_ID=<client-id-from-authentik>
* OIDC_CLIENT_SECRET=<client-secret-from-authentik>
* OIDC_REDIRECT_URI=https://bills.example.com/api/auth/oidc/callback
* OIDC_SCOPES="openid email profile groups"
* OIDC_ADMIN_GROUP=bill-tracker-admins
* OIDC_DEFAULT_ROLE=user
* OIDC_AUTO_PROVISION=true
* OIDC_PROVIDER_NAME=authentik
*
* DB settings take precedence. Env values are used only while the
* corresponding DB value is blank.
*
* 6. Keep local login enabled until authentik login is tested and working.
*/
const crypto = require('crypto');
const { Issuer } = require('openid-client');
const { getDb, getSetting } = require('../db/database');
// ── Configuration ─────────────────────────────────────────────────────────────
function trimOrNull(value) {
if (value === undefined || value === null) return null;
const s = String(value).trim();
return s || null;
}
function settingOrEnv(settingKey, envKey, fallback = null) {
return trimOrNull(getSetting(settingKey)) || trimOrNull(process.env[envKey]) || fallback;
}
function boolSettingOrEnv(settingKey, envKey, fallback) {
const dbValue = trimOrNull(getSetting(settingKey));
if (dbValue !== null) return dbValue === 'true';
const envValue = trimOrNull(process.env[envKey]);
if (envValue !== null) return envValue === 'true';
return fallback;
}
function normalizeScopes(value) {
const scopes = String(value || 'openid email profile groups').split(/\s+/).filter(Boolean);
return scopes.length ? scopes : ['openid', 'email', 'profile', 'groups'];
}
function normalizeTokenAuthMethod(value) {
return value === 'client_secret_post' ? 'client_secret_post' : 'client_secret_basic';
}
/**
* Returns the effective OIDC config, or null if required settings are missing.
* DB settings win; env vars are optional fallback/bootstrap defaults.
* Never returns client_secret in a form intended for clients.
*/
function getOidcConfig() {
const issuerUrl = settingOrEnv('oidc_issuer_url', 'OIDC_ISSUER_URL');
const clientId = settingOrEnv('oidc_client_id', 'OIDC_CLIENT_ID');
const clientSecret = settingOrEnv('oidc_client_secret', 'OIDC_CLIENT_SECRET');
const redirectUri = settingOrEnv('oidc_redirect_uri', 'OIDC_REDIRECT_URI');
const tokenAuthMethod = settingOrEnv('oidc_token_auth_method', 'OIDC_TOKEN_AUTH_METHOD', 'client_secret_basic');
if (!issuerUrl || !clientId || !clientSecret || !redirectUri) {
return null;
}
return {
enabled: true,
issuerUrl,
clientId,
clientSecret, // server-side only; never returned to clients or logged
tokenEndpointAuthMethod: normalizeTokenAuthMethod(tokenAuthMethod),
redirectUri,
scopes: normalizeScopes(settingOrEnv('oidc_scopes', 'OIDC_SCOPES', 'openid email profile groups')),
adminGroup: settingOrEnv('oidc_admin_group', 'OIDC_ADMIN_GROUP'),
defaultRole: 'user',
autoProvision: boolSettingOrEnv('oidc_auto_provision', 'OIDC_AUTO_PROVISION', true),
providerName: settingOrEnv('oidc_provider_name', 'OIDC_PROVIDER_NAME', 'authentik'),
};
}
function getOidcConfigStatus() {
const issuerUrl = settingOrEnv('oidc_issuer_url', 'OIDC_ISSUER_URL');
const clientId = settingOrEnv('oidc_client_id', 'OIDC_CLIENT_ID');
const clientSecret = settingOrEnv('oidc_client_secret', 'OIDC_CLIENT_SECRET');
const redirectUri = settingOrEnv('oidc_redirect_uri', 'OIDC_REDIRECT_URI');
const missing = [];
if (!issuerUrl) missing.push('issuer URL');
if (!clientId) missing.push('client ID');
if (!clientSecret) missing.push('client secret');
if (!redirectUri) missing.push('redirect URI');
return {
oidc_configured: !!(issuerUrl && clientId && clientSecret && redirectUri),
oidc_issuer_url_set: !!issuerUrl,
oidc_client_id_set: !!clientId,
oidc_client_secret_set: !!clientSecret,
oidc_redirect_uri_set: !!redirectUri,
oidc_missing_fields: missing,
};
}
function getAdminOidcSettings() {
const effectiveConfig = getOidcConfig();
return {
oidc_provider_name: settingOrEnv('oidc_provider_name', 'OIDC_PROVIDER_NAME', 'authentik'),
oidc_issuer_url: settingOrEnv('oidc_issuer_url', 'OIDC_ISSUER_URL', ''),
oidc_client_id: settingOrEnv('oidc_client_id', 'OIDC_CLIENT_ID', ''),
oidc_token_auth_method: normalizeTokenAuthMethod(settingOrEnv('oidc_token_auth_method', 'OIDC_TOKEN_AUTH_METHOD', 'client_secret_basic')),
oidc_redirect_uri: settingOrEnv('oidc_redirect_uri', 'OIDC_REDIRECT_URI', ''),
oidc_scopes: normalizeScopes(settingOrEnv('oidc_scopes', 'OIDC_SCOPES', 'openid email profile groups')).join(' '),
oidc_admin_group: settingOrEnv('oidc_admin_group', 'OIDC_ADMIN_GROUP', ''),
oidc_default_role: 'user',
oidc_auto_provision: boolSettingOrEnv('oidc_auto_provision', 'OIDC_AUTO_PROVISION', true),
oidc_client_secret_set: getOidcConfigStatus().oidc_client_secret_set,
oidc_env_fallback_used: !effectiveConfig
? false
: ['oidc_issuer_url', 'oidc_client_id', 'oidc_client_secret', 'oidc_redirect_uri']
.some(k => !trimOrNull(getSetting(k))),
};
}
/**
* Returns whether OIDC login is both configured and enabled by admin.
*/
function isOidcLoginActive() {
if (getSetting('oidc_login_enabled') !== 'true') return false;
return getOidcConfig() !== null;
}
/** Safe public OIDC info — no secrets, suitable for unauthenticated responses. */
function getPublicOidcInfo() {
try {
const active = isOidcLoginActive();
if (!active) return { oidc_enabled: false };
const cfg = getOidcConfig();
const providerName = cfg?.providerName || 'authentik';
return {
oidc_enabled: true,
oidc_provider_name: providerName,
oidc_login_url: '/api/auth/oidc/login',
};
} catch {
return { oidc_enabled: false };
}
}
// ── openid-client Issuer / Client cache ───────────────────────────────────────
// Cache keyed by issuer/client/redirect so Admin config changes pick up a
// fresh client. TTL prevents stale JWKS when the provider rotates keys.
let _cachedClient = null;
let _cachedClientKey = null;
let _cacheTs = 0;
const CLIENT_CACHE_TTL = 60 * 60 * 1000; // 1 hour — JWKS key rotation window
/**
* Returns a cached openid-client Client for the given config.
* Performs OIDC discovery (fetching jwks_uri and endpoints) on first call
* or when the cache has expired. Signature verification uses the discovered
* JWKS URI automatically via openid-client@5.
*/
async function getOidcClient(config) {
const now = Date.now();
const clientKey = [config.issuerUrl, config.clientId, config.redirectUri, config.tokenEndpointAuthMethod].join('|');
if (
_cachedClient &&
_cachedClientKey === clientKey &&
now - _cacheTs < CLIENT_CACHE_TTL
) {
return _cachedClient;
}
const issuer = await Issuer.discover(config.issuerUrl);
_cachedClient = new issuer.Client({
client_id: config.clientId,
client_secret: config.clientSecret,
token_endpoint_auth_method: config.tokenEndpointAuthMethod,
redirect_uris: [config.redirectUri],
response_types: ['code'],
});
_cachedClientKey = clientKey;
_cacheTs = now;
return _cachedClient;
}
async function testOidcConfiguration(config = getOidcConfig()) {
if (!config) {
return {
ok: false,
error: 'OIDC configuration is incomplete.',
missing: getOidcConfigStatus().oidc_missing_fields,
};
}
try {
const client = await getOidcClient(config);
const metadata = client.issuer?.metadata || {};
const missingMetadata = [];
if (!metadata.authorization_endpoint) missingMetadata.push('authorization_endpoint');
if (!metadata.token_endpoint) missingMetadata.push('token_endpoint');
if (!metadata.jwks_uri) missingMetadata.push('jwks_uri');
if (missingMetadata.length) {
return {
ok: false,
error: `OIDC discovery is missing required metadata: ${missingMetadata.join(', ')}.`,
};
}
return {
ok: true,
issuer: metadata.issuer || config.issuerUrl,
provider_name: config.providerName,
redirect_uri: config.redirectUri,
scopes: config.scopes.join(' '),
};
} catch (err) {
const message = err.message || 'OIDC discovery failed.';
const looks404 = /404|not found/i.test(message);
return {
ok: false,
error: looks404
? 'OIDC discovery returned 404. Check the Issuer URL; it should be the provider issuer, not /authorize/ or another endpoint.'
: message,
};
}
}
/** Invalidates the client cache (e.g. after a config change or key rotation). */
function invalidateClientCache() {
_cachedClient = null;
_cachedClientKey = null;
_cacheTs = 0;
}
// ── PKCE helpers ──────────────────────────────────────────────────────────────
function generateCodeVerifier() {
return crypto.randomBytes(32).toString('base64url');
}
function generateCodeChallenge(verifier) {
return crypto.createHash('sha256').update(verifier).digest('base64url');
}
// ── Login state (PKCE + nonce, stored in DB with 5-minute TTL) ───────────────
const STATE_TTL_MS = 5 * 60 * 1000;
function sanitizeRedirectTo(value) {
if (!value || typeof value !== 'string') return null;
const s = value.trim();
if (!s.startsWith('/') || s.startsWith('//') || /[:\0]/.test(s)) return null;
return s.slice(0, 200);
}
function createLoginState(redirectTo) {
const db = getDb();
const id = crypto.randomUUID();
const nonce = crypto.randomBytes(16).toString('hex');
const codeVerifier = generateCodeVerifier();
const now = new Date();
const expiresAt = new Date(now.getTime() + STATE_TTL_MS).toISOString();
db.prepare(`
INSERT INTO oidc_states (id, nonce, code_verifier, redirect_to, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?)
`).run(id, nonce, codeVerifier, sanitizeRedirectTo(redirectTo), now.toISOString(), expiresAt);
// Prune stale states to keep the table small
db.prepare("DELETE FROM oidc_states WHERE expires_at <= datetime('now')").run();
return { id, nonce, codeVerifier };
}
/** Retrieve and immediately delete — prevents state replay attacks. */
function consumeLoginState(stateId) {
if (!stateId || typeof stateId !== 'string') return null;
const db = getDb();
const row = db.prepare(`
SELECT id, nonce, code_verifier, redirect_to
FROM oidc_states
WHERE id = ? AND expires_at > datetime('now')
`).get(stateId);
if (!row) return null;
db.prepare('DELETE FROM oidc_states WHERE id = ?').run(stateId);
return row;
}
// ── Authorization URL ─────────────────────────────────────────────────────────
async function buildAuthorizationUrl(config, state) {
const client = await getOidcClient(config);
const codeChallenge = generateCodeChallenge(state.codeVerifier);
// client.authorizationUrl() uses the discovered authorization_endpoint
return client.authorizationUrl({
scope: config.scopes.join(' '),
redirect_uri: config.redirectUri,
state: state.id,
nonce: state.nonce,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
});
}
// ── Token exchange + full ID token verification ───────────────────────────────
/**
* Exchanges the authorization code for tokens and performs complete ID token
* validation using openid-client@5:
*
* State parameter matches saved state (replay protection)
* PKCE code_verifier / code_challenge round-trip
* JWT signature verification using JWKS from OIDC discovery
* Issuer validation (iss claim must match configured issuer URL)
* Audience validation (aud claim must include client_id)
* Expiry validation (exp must be in the future)
* Not-before validation (nbf with 30s clock-skew tolerance)
* Nonce validation (replay attack prevention)
* sub claim present and non-empty
*
* Tokens are never logged. Returns verified claims on success.
*/
async function exchangeAndVerifyTokens(config, code, stateId, savedState) {
const client = await getOidcClient(config);
// client.callback() handles token exchange + full validation in one step.
// It throws OPError (provider error) or RPError (validation failure) on any problem.
const tokenSet = await client.callback(
config.redirectUri,
{ code, state: stateId }, // parameters from the callback URL
{
code_verifier: savedState.code_verifier,
nonce: savedState.nonce,
state: stateId, // ensures state param matches expected value
},
);
const claims = tokenSet.claims();
if (!claims.sub) throw new Error('ID token missing required sub claim');
return claims;
}
// ── Role / group mapping ──────────────────────────────────────────────────────
/**
* Maps OIDC group claims to a local role.
* Admin role is NEVER granted unless the user is explicitly in the configured
* admin group. Missing group claim defaults to user role.
* Default role is always user; admin is granted only by explicit group match.
*/
function mapRoleFromClaims(claims, config) {
const adminGroup = settingOrEnv('oidc_admin_group', 'OIDC_ADMIN_GROUP') || config.adminGroup;
const defaultRole = 'user';
if (!adminGroup) return defaultRole;
const groups = claims.groups || [];
const list = Array.isArray(groups) ? groups : [groups].filter(Boolean);
return list.includes(adminGroup) ? 'admin' : defaultRole;
}
// ── User provisioning ─────────────────────────────────────────────────────────
/**
* Finds an existing user by OIDC subject, optionally links by verified email,
* or auto-provisions a new user.
*
* Lookup order:
* 1. auth_provider='oidc' + external_subject=sub (most stable)
* 2. email match on existing local user (only if email_verified=true)
* 3. Auto-provision if OIDC_AUTO_PROVISION=true
*
* OIDC-provisioned users have an empty password_hash and cannot use local
* password login (enforced in authService.login()).
*/
async function findOrProvisionUser(claims, config) {
const db = getDb();
const sub = claims.sub;
const provider = 'oidc';
const autoProvision = config.autoProvision === true;
// 1. Stable sub-based lookup
let user = db.prepare(
'SELECT * FROM users WHERE auth_provider = ? AND external_subject = ?'
).get(provider, sub);
// 2. Email-based link — only if email is present and verified
if (!user && claims.email && claims.email_verified === true) {
const existing = db.prepare(
"SELECT * FROM users WHERE email = ? AND auth_provider = 'local' LIMIT 1"
).get(claims.email);
if (existing) {
db.prepare(`
UPDATE users
SET auth_provider = ?, external_subject = ?, email = ?, updated_at = datetime('now')
WHERE id = ?
`).run(provider, sub, claims.email, existing.id);
user = db.prepare('SELECT * FROM users WHERE id = ?').get(existing.id);
console.log(`[oidc] Linked existing local account #${user.id} to OIDC identity`);
}
}
// 3. Auto-provision
if (!user) {
if (!autoProvision) {
throw Object.assign(
new Error('No matching account and auto-provisioning is disabled.'),
{ status: 403 },
);
}
const role = mapRoleFromClaims(claims, config);
const baseUsername = (claims.preferred_username || claims.email || sub)
.slice(0, 60).replace(/[^a-zA-Z0-9._@-]/g, '_');
const displayName = claims.name || claims.preferred_username || null;
const email = claims.email || null;
let finalUsername = baseUsername;
let suffix = 0;
while (db.prepare('SELECT id FROM users WHERE username = ?').get(finalUsername)) {
suffix++;
finalUsername = `${baseUsername}_${suffix}`;
}
const result = db.prepare(`
INSERT INTO users
(username, password_hash, role, auth_provider, external_subject,
email, display_name, first_login, must_change_password, last_login_at)
VALUES (?, '', ?, ?, ?, ?, ?, 0, 0, datetime('now'))
`).run(finalUsername, role, provider, sub, email, displayName);
user = db.prepare('SELECT * FROM users WHERE id = ?').get(result.lastInsertRowid);
console.log(`[oidc] Provisioned new ${role} user from OIDC login`);
}
// Refresh login timestamp and display name from provider claims
db.prepare(`
UPDATE users
SET last_login_at = datetime('now'),
display_name = COALESCE(?, display_name),
updated_at = datetime('now')
WHERE id = ?
`).run(claims.name || null, user.id);
return db.prepare('SELECT * FROM users WHERE id = ?').get(user.id);
}
module.exports = {
getOidcConfig,
getOidcConfigStatus,
getAdminOidcSettings,
isOidcLoginActive,
getPublicOidcInfo,
getOidcClient,
testOidcConfiguration,
invalidateClientCache,
createLoginState,
consumeLoginState,
buildAuthorizationUrl,
exchangeAndVerifyTokens,
mapRoleFromClaims,
findOrProvisionUser,
// Exported for testing
generateCodeVerifier,
generateCodeChallenge,
sanitizeRedirectTo,
};