feat(auth): secure backend authentication modes
This commit is contained in:
@@ -9,5 +9,9 @@ DB_NAME=eme_db
|
|||||||
DB_USER=sa
|
DB_USER=sa
|
||||||
DB_PASSWORD=YourStrongPassword123!
|
DB_PASSWORD=YourStrongPassword123!
|
||||||
|
|
||||||
|
AUTH_MODE=demo
|
||||||
AZURE_TENANT_ID=
|
AZURE_TENANT_ID=
|
||||||
AZURE_CLIENT_ID=
|
AZURE_API_CLIENT_ID=
|
||||||
|
AZURE_API_AUDIENCE=
|
||||||
|
AZURE_API_SCOPE=access_as_user
|
||||||
|
AZURE_ALLOWED_EMAIL_DOMAINS=ensup.eu,ensitech.eu
|
||||||
|
|||||||
Generated
+58
-3
@@ -14,7 +14,8 @@
|
|||||||
"cors": "^2.8.6",
|
"cors": "^2.8.6",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"jsonwebtoken": "^9.0.3"
|
"jsonwebtoken": "^9.0.3",
|
||||||
|
"jwks-rsa": "^4.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
@@ -1068,7 +1069,6 @@
|
|||||||
"version": "9.0.10",
|
"version": "9.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
|
||||||
"integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
|
"integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/ms": "*",
|
"@types/ms": "*",
|
||||||
@@ -1079,7 +1079,6 @@
|
|||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
||||||
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
@@ -3074,6 +3073,15 @@
|
|||||||
"jiti": "lib/jiti-cli.mjs"
|
"jiti": "lib/jiti-cli.mjs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jose": {
|
||||||
|
"version": "6.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.4.tgz",
|
||||||
|
"integrity": "sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/js-md4": {
|
"node_modules/js-md4": {
|
||||||
"version": "0.3.2",
|
"version": "0.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/js-md4/-/js-md4-0.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/js-md4/-/js-md4-0.3.2.tgz",
|
||||||
@@ -3134,6 +3142,23 @@
|
|||||||
"safe-buffer": "^5.0.1"
|
"safe-buffer": "^5.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jwks-rsa": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-sbkByqyATKYJP5F4RXj03N5TUNC0QLTjCAZvwTzC4BwJZ8e0/cWxN8YROnyUth2g1/ONWi4eSFHeu6oYalrc3Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/jsonwebtoken": "^9.0.4",
|
||||||
|
"debug": "^4.3.4",
|
||||||
|
"jose": "^6.1.3",
|
||||||
|
"limiter": "^1.1.5",
|
||||||
|
"lru-cache": "^11.0.0",
|
||||||
|
"lru-memoizer": "^3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.12.0 || >= 23.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/jws": {
|
"node_modules/jws": {
|
||||||
"version": "4.0.1",
|
"version": "4.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
|
||||||
@@ -3168,6 +3193,11 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/limiter": {
|
||||||
|
"version": "1.1.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz",
|
||||||
|
"integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA=="
|
||||||
|
},
|
||||||
"node_modules/locate-path": {
|
"node_modules/locate-path": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||||
@@ -3184,6 +3214,12 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash.clonedeep": {
|
||||||
|
"version": "4.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
|
||||||
|
"integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lodash.includes": {
|
"node_modules/lodash.includes": {
|
||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
||||||
@@ -3233,6 +3269,25 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/lru-cache": {
|
||||||
|
"version": "11.5.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
|
||||||
|
"integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"engines": {
|
||||||
|
"node": "20 || >=22"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lru-memoizer": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-m83w/cYXLdUIboKSPxzPAGfYnk+vqeDYXuoSrQRw1q+yVEd8IXhvMufN8Q5TIPe7e2jyX4SRNrDJI2Skw1yznQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"lodash.clonedeep": "^4.5.0",
|
||||||
|
"lru-cache": "^11.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lru.min": {
|
"node_modules/lru.min": {
|
||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz",
|
||||||
|
|||||||
@@ -43,6 +43,7 @@
|
|||||||
"cors": "^2.8.6",
|
"cors": "^2.8.6",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"jsonwebtoken": "^9.0.3"
|
"jsonwebtoken": "^9.0.3",
|
||||||
|
"jwks-rsa": "^4.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import dotenv from 'dotenv';
|
|||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
|
type AuthMode = 'demo' | 'azure';
|
||||||
|
|
||||||
function required(name: string): string {
|
function required(name: string): string {
|
||||||
const value = process.env[name];
|
const value = process.env[name];
|
||||||
|
|
||||||
@@ -28,9 +30,50 @@ function numberFromEnv(name: string, defaultValue: number): number {
|
|||||||
return parsed;
|
return parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function authModeFromEnv(nodeEnv: string): AuthMode {
|
||||||
|
const value = process.env.AUTH_MODE || (nodeEnv === 'production' ? 'azure' : 'demo');
|
||||||
|
|
||||||
|
if (value !== 'demo' && value !== 'azure') {
|
||||||
|
throw new Error('Variable d’environnement invalide: AUTH_MODE');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nodeEnv === 'production' && value === 'demo') {
|
||||||
|
throw new Error('AUTH_MODE=demo est interdit en production');
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function azureValue(name: string, authMode: AuthMode): string {
|
||||||
|
const value = process.env[name];
|
||||||
|
|
||||||
|
if (authMode === 'azure' && !value) {
|
||||||
|
throw new Error(`Variable d'environnement manquante en mode Azure: ${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function listFromEnv(name: string, defaultValue: string[]): string[] {
|
||||||
|
const value = process.env[name];
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
.split(',')
|
||||||
|
.map((item) => item.trim().toLowerCase())
|
||||||
|
.filter((item) => item.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeEnv = process.env.NODE_ENV || 'development';
|
||||||
|
const authMode = authModeFromEnv(nodeEnv);
|
||||||
|
const azureApiClientId = azureValue('AZURE_API_CLIENT_ID', authMode);
|
||||||
|
|
||||||
export const env = {
|
export const env = {
|
||||||
port: numberFromEnv('PORT', 3000),
|
port: numberFromEnv('PORT', 3000),
|
||||||
nodeEnv: process.env.NODE_ENV || 'development',
|
nodeEnv,
|
||||||
frontendUrl: required('FRONTEND_URL'),
|
frontendUrl: required('FRONTEND_URL'),
|
||||||
databaseUrl: required('DATABASE_URL'),
|
databaseUrl: required('DATABASE_URL'),
|
||||||
db: {
|
db: {
|
||||||
@@ -40,8 +83,17 @@ export const env = {
|
|||||||
user: required('DB_USER'),
|
user: required('DB_USER'),
|
||||||
password: required('DB_PASSWORD'),
|
password: required('DB_PASSWORD'),
|
||||||
},
|
},
|
||||||
azure: {
|
auth: {
|
||||||
tenantId: required('AZURE_TENANT_ID'),
|
mode: authMode,
|
||||||
clientId: required('AZURE_CLIENT_ID'),
|
azure: {
|
||||||
|
tenantId: azureValue('AZURE_TENANT_ID', authMode),
|
||||||
|
apiClientId: azureApiClientId,
|
||||||
|
audience: process.env.AZURE_API_AUDIENCE || azureApiClientId,
|
||||||
|
scope: process.env.AZURE_API_SCOPE || 'access_as_user',
|
||||||
|
allowedEmailDomains: listFromEnv('AZURE_ALLOWED_EMAIL_DOMAINS', [
|
||||||
|
'ensup.eu',
|
||||||
|
'ensitech.eu',
|
||||||
|
]),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,28 +1,10 @@
|
|||||||
import { Request, Response, NextFunction } from 'express';
|
import { Request, Response, NextFunction } from 'express';
|
||||||
import { AppError } from '../errors/app-error';
|
import { authenticateUser } from '../services/authentication.service';
|
||||||
import { findByEmail } from '../repositories/utilisateur.repository';
|
|
||||||
import { AuthenticatedUser } from '../types/authenticated-user';
|
|
||||||
|
|
||||||
/* Auth simulée (temporaire) : en attendant Azure AD, l'utilisateur courant est
|
|
||||||
résolu depuis l'en-tête `x-user-email`. À remplacer par la validation du JWT. */
|
|
||||||
export async function currentUser(req: Request, _res: Response, next: NextFunction): Promise<void> {
|
export async function currentUser(req: Request, _res: Response, next: NextFunction): Promise<void> {
|
||||||
const email = req.header('x-user-email');
|
req.user = await authenticateUser({
|
||||||
if (!email) {
|
authorization: req.header('authorization'),
|
||||||
throw new AppError(401, 'En-tete x-user-email requis (auth simulee)');
|
demoEmail: req.header('x-user-email'),
|
||||||
}
|
});
|
||||||
|
|
||||||
const utilisateur = await findByEmail(email);
|
|
||||||
if (!utilisateur || !utilisateur.actif) {
|
|
||||||
throw new AppError(401, 'Utilisateur non reconnu');
|
|
||||||
}
|
|
||||||
|
|
||||||
const authenticated: AuthenticatedUser = {
|
|
||||||
id: utilisateur.id,
|
|
||||||
email: utilisateur.email,
|
|
||||||
roleCode: utilisateur.role.code,
|
|
||||||
campusId: utilisateur.campusId,
|
|
||||||
};
|
|
||||||
|
|
||||||
req.user = authenticated;
|
|
||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,13 @@ export function findByEmail(email: string): Promise<UtilisateurAvecRole | null>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function findByMicrosoftId(microsoftId: string): Promise<UtilisateurAvecRole | null> {
|
||||||
|
return prisma.utilisateur.findUnique({
|
||||||
|
where: { microsoftId },
|
||||||
|
include: { role: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function findById(id: number): Promise<UtilisateurProfil | null> {
|
export function findById(id: number): Promise<UtilisateurProfil | null> {
|
||||||
return prisma.utilisateur.findUnique({
|
return prisma.utilisateur.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { env } from '../config/env';
|
||||||
|
import { AppError } from '../errors/app-error';
|
||||||
|
import {
|
||||||
|
findByEmail,
|
||||||
|
findByMicrosoftId,
|
||||||
|
UtilisateurAvecRole,
|
||||||
|
} from '../repositories/utilisateur.repository';
|
||||||
|
import { AuthenticatedUser } from '../types/authenticated-user';
|
||||||
|
import { verifyAzureAccessToken } from './azure-token.service';
|
||||||
|
|
||||||
|
export interface AuthenticationHeaders {
|
||||||
|
authorization?: string;
|
||||||
|
demoEmail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toAuthenticatedUser(utilisateur: UtilisateurAvecRole): AuthenticatedUser {
|
||||||
|
return {
|
||||||
|
id: utilisateur.id,
|
||||||
|
email: utilisateur.email,
|
||||||
|
roleCode: utilisateur.role.code,
|
||||||
|
campusId: utilisateur.campusId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function bearerToken(authorization: string | undefined): string {
|
||||||
|
const match = authorization?.match(/^Bearer\s+(\S+)$/i);
|
||||||
|
if (!match) {
|
||||||
|
throw new AppError(401, 'Jeton Bearer requis');
|
||||||
|
}
|
||||||
|
|
||||||
|
return match[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function authenticateDemo(email: string | undefined): Promise<UtilisateurAvecRole | null> {
|
||||||
|
if (!email) {
|
||||||
|
throw new AppError(401, 'En-tete x-user-email requis en mode demo');
|
||||||
|
}
|
||||||
|
|
||||||
|
return findByEmail(email.trim().toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function authenticateAzure(
|
||||||
|
authorization: string | undefined,
|
||||||
|
): Promise<UtilisateurAvecRole | null> {
|
||||||
|
const identity = await verifyAzureAccessToken(bearerToken(authorization));
|
||||||
|
const byMicrosoftId = await findByMicrosoftId(identity.microsoftId);
|
||||||
|
|
||||||
|
if (byMicrosoftId) {
|
||||||
|
return byMicrosoftId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return findByEmail(identity.email);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function authenticateUser(
|
||||||
|
headers: AuthenticationHeaders,
|
||||||
|
): Promise<AuthenticatedUser> {
|
||||||
|
const utilisateur =
|
||||||
|
env.auth.mode === 'azure'
|
||||||
|
? await authenticateAzure(headers.authorization)
|
||||||
|
: await authenticateDemo(headers.demoEmail);
|
||||||
|
|
||||||
|
if (!utilisateur || !utilisateur.actif) {
|
||||||
|
throw new AppError(401, 'Utilisateur non reconnu');
|
||||||
|
}
|
||||||
|
|
||||||
|
return toAuthenticatedUser(utilisateur);
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import jwt, {
|
||||||
|
GetPublicKeyOrSecret,
|
||||||
|
JwtHeader,
|
||||||
|
JwtPayload,
|
||||||
|
SigningKeyCallback,
|
||||||
|
} from 'jsonwebtoken';
|
||||||
|
import jwksClient from 'jwks-rsa';
|
||||||
|
import { env } from '../config/env';
|
||||||
|
import { AppError } from '../errors/app-error';
|
||||||
|
|
||||||
|
export interface AzureIdentity {
|
||||||
|
microsoftId: string;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AzureClaims extends JwtPayload {
|
||||||
|
oid?: string;
|
||||||
|
tid?: string;
|
||||||
|
scp?: string;
|
||||||
|
preferred_username?: string;
|
||||||
|
upn?: string;
|
||||||
|
email?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const issuer = `https://login.microsoftonline.com/${env.auth.azure.tenantId}/v2.0`;
|
||||||
|
const client = jwksClient({
|
||||||
|
jwksUri: `${issuer}/discovery/v2.0/keys`,
|
||||||
|
cache: true,
|
||||||
|
cacheMaxEntries: 5,
|
||||||
|
cacheMaxAge: 10 * 60 * 1000,
|
||||||
|
rateLimit: true,
|
||||||
|
jwksRequestsPerMinute: 10,
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const getSigningKey: GetPublicKeyOrSecret = (
|
||||||
|
header: JwtHeader,
|
||||||
|
callback: SigningKeyCallback,
|
||||||
|
): void => {
|
||||||
|
if (!header.kid) {
|
||||||
|
callback(new Error('Identifiant de cle de signature absent'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
client.getSigningKey(header.kid, (error, key) => {
|
||||||
|
if (error || !key) {
|
||||||
|
callback(error || new Error('Cle de signature Microsoft introuvable'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
callback(null, key.getPublicKey());
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
function verifyJwt(token: string): Promise<AzureClaims> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
jwt.verify(
|
||||||
|
token,
|
||||||
|
getSigningKey,
|
||||||
|
{
|
||||||
|
algorithms: ['RS256'],
|
||||||
|
audience: env.auth.azure.audience,
|
||||||
|
issuer,
|
||||||
|
},
|
||||||
|
(error, decoded) => {
|
||||||
|
if (error || !decoded || typeof decoded === 'string') {
|
||||||
|
reject(error || new Error('Contenu du jeton invalide'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(decoded);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function emailFromClaims(claims: AzureClaims): string | null {
|
||||||
|
const email = claims.preferred_username || claims.upn || claims.email;
|
||||||
|
return email?.trim().toLowerCase() || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasAllowedEmailDomain(email: string): boolean {
|
||||||
|
const separatorIndex = email.lastIndexOf('@');
|
||||||
|
if (separatorIndex < 1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const domain = email.slice(separatorIndex + 1);
|
||||||
|
return env.auth.azure.allowedEmailDomains.includes(domain);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyAzureAccessToken(token: string): Promise<AzureIdentity> {
|
||||||
|
if (env.auth.mode !== 'azure') {
|
||||||
|
throw new AppError(500, 'Validation Azure indisponible hors du mode Azure');
|
||||||
|
}
|
||||||
|
|
||||||
|
let claims: AzureClaims;
|
||||||
|
try {
|
||||||
|
claims = await verifyJwt(token);
|
||||||
|
} catch {
|
||||||
|
throw new AppError(401, 'Jeton Microsoft invalide ou expire');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (claims.tid !== env.auth.azure.tenantId) {
|
||||||
|
throw new AppError(401, 'Tenant Microsoft non autorise');
|
||||||
|
}
|
||||||
|
|
||||||
|
const scopes = claims.scp?.split(' ') || [];
|
||||||
|
if (!scopes.includes(env.auth.azure.scope)) {
|
||||||
|
throw new AppError(403, 'Permission Microsoft insuffisante');
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = emailFromClaims(claims);
|
||||||
|
if (!claims.oid || !email || !hasAllowedEmailDomain(email)) {
|
||||||
|
throw new AppError(401, 'Identite Microsoft non autorisee');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
microsoftId: claims.oid,
|
||||||
|
email,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user