Initial
This commit is contained in:
87
server/services/entraService.js
Normal file
87
server/services/entraService.js
Normal 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 || ''
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user