This commit is contained in:
2026-06-25 12:05:53 -05:00
commit b58e50e423
141 changed files with 28190 additions and 0 deletions

View File

@@ -0,0 +1,87 @@
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 || ''
};
}