88 lines
3.4 KiB
JavaScript
88 lines
3.4 KiB
JavaScript
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 || ''
|
|
};
|
|
}
|