import jwt from 'jsonwebtoken'; import { config } from '../config.js'; import { getSetting, getBooleanSetting } from '../models/settingsModel.js'; // Microsoft Entra ID (Azure AD) OAuth 2.0 authorization-code login. // // Tenant/client/callback values are read from the effective settings so admins // can adjust them in the Config screen, while the client secret stays in the // process environment (never persisted to the settings table). The flow uses // Node's global fetch and the existing jsonwebtoken dependency for signed // state, so no new packages are required. const AUTHORITY_HOST = 'https://login.microsoftonline.com'; const GRAPH_ME_URL = 'https://graph.microsoft.com/v1.0/me'; const LOGIN_SCOPE = 'openid profile email https://graph.microsoft.com/User.Read'; export function entraSettings() { return { enabled: getBooleanSetting('entra_enabled', config.entra.enabled), tenantId: getSetting('entra_tenant_id') || config.entra.tenantId, clientId: getSetting('entra_client_id') || config.entra.clientId, clientSecret: config.entra.clientSecret, callbackUrl: getSetting('entra_callback_url') || config.entra.callbackUrl }; } // Login is only offered when Entra is enabled AND fully configured. Missing any // piece (common in dev) means we fall back to local auth without erroring. export function isEntraLoginConfigured() { const s = entraSettings(); return Boolean(s.enabled && s.tenantId && s.clientId && s.clientSecret && s.callbackUrl); } export function signState(payload = {}) { return jwt.sign({ ...payload, purpose: 'entra-state' }, config.jwtSecret, { expiresIn: '10m' }); } export function verifyState(state) { const decoded = jwt.verify(state, config.jwtSecret); if (decoded.purpose !== 'entra-state') throw new Error('Invalid state'); return decoded; } export function buildAuthorizeUrl(state) { const s = entraSettings(); const params = new URLSearchParams({ client_id: s.clientId, response_type: 'code', redirect_uri: s.callbackUrl, response_mode: 'query', scope: LOGIN_SCOPE, state }); return `${AUTHORITY_HOST}/${encodeURIComponent(s.tenantId)}/oauth2/v2.0/authorize?${params.toString()}`; } export async function exchangeCodeForToken(code) { const s = entraSettings(); const response = await fetch(`${AUTHORITY_HOST}/${encodeURIComponent(s.tenantId)}/oauth2/v2.0/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ client_id: s.clientId, client_secret: s.clientSecret, grant_type: 'authorization_code', code, redirect_uri: s.callbackUrl, scope: LOGIN_SCOPE }) }); const payload = await response.json().catch(() => ({})); if (!response.ok) throw new Error(payload.error_description || payload.error || `Token exchange failed: ${response.status}`); return payload; } export async function fetchEntraProfile(accessToken) { const response = await fetch(GRAPH_ME_URL, { headers: { Authorization: `Bearer ${accessToken}` } }); const payload = await response.json().catch(() => ({})); if (!response.ok) throw new Error(payload?.error?.message || `Profile lookup failed: ${response.status}`); const email = (payload.mail || payload.userPrincipalName || '').toLowerCase(); if (!email) throw new Error('Entra profile did not include an email or user principal name'); return { email, displayName: payload.displayName || email, oid: payload.id || '' }; }