510 lines
19 KiB
JavaScript
510 lines
19 KiB
JavaScript
|
|
'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,
|
||
|
|
};
|