docs: archive entra materials and simplify deployment docs
This commit is contained in:
+1
-2
@@ -41,7 +41,7 @@ This directory contains shared libraries and packages used across services and a
|
||||
### Infrastructure
|
||||
|
||||
#### `storage/`
|
||||
- **Purpose**: Storage abstraction (S3/GCS/Azure)
|
||||
- **Purpose**: Storage abstraction (S3/GCS)
|
||||
- **Used By**: Intake, Dataroom, Legal Documents services
|
||||
- **Key Features**: WORM storage, object lifecycle
|
||||
|
||||
@@ -142,4 +142,3 @@ Packages can depend on other packages:
|
||||
---
|
||||
|
||||
**Last Updated**: 2025-01-27
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@azure/identity": "^4.0.1",
|
||||
"@noble/ed25519": "^2.0.0",
|
||||
"@types/node-fetch": "^2.6.11",
|
||||
"base58-universal": "^2.0.0",
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
/**
|
||||
* Azure Logic Apps connector
|
||||
* Provides integration with Azure Logic Apps for workflow orchestration
|
||||
*/
|
||||
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
export interface LogicAppsConfig {
|
||||
workflowUrl: string;
|
||||
accessKey?: string;
|
||||
managedIdentityClientId?: string;
|
||||
}
|
||||
|
||||
export interface LogicAppsTriggerRequest {
|
||||
triggerName?: string;
|
||||
body?: Record<string, unknown>;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface LogicAppsResponse {
|
||||
statusCode: number;
|
||||
body?: unknown;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Azure Logic Apps client
|
||||
*/
|
||||
export class AzureLogicAppsClient {
|
||||
constructor(private config: LogicAppsConfig) {}
|
||||
|
||||
/**
|
||||
* Trigger a Logic App workflow
|
||||
*/
|
||||
async triggerWorkflow(
|
||||
request: LogicAppsTriggerRequest
|
||||
): Promise<LogicAppsResponse> {
|
||||
const url = this.config.accessKey
|
||||
? `${this.config.workflowUrl}?api-version=2016-10-01&sp=/triggers/${request.triggerName || 'manual'}/run&sv=1.0&sig=${this.config.accessKey}`
|
||||
: `${this.config.workflowUrl}/triggers/${request.triggerName || 'manual'}/run?api-version=2016-10-01`;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...request.headers,
|
||||
};
|
||||
|
||||
// If using managed identity, add Authorization header
|
||||
if (this.config.managedIdentityClientId && !this.config.accessKey) {
|
||||
// In production, get token from Azure Managed Identity endpoint
|
||||
// This is a placeholder - actual implementation would use @azure/identity
|
||||
headers['Authorization'] = `Bearer ${await this.getManagedIdentityToken()}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: request.body ? JSON.stringify(request.body) : undefined,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Failed to trigger Logic App: ${response.status} ${errorText}`);
|
||||
}
|
||||
|
||||
const responseBody = await response.json().catch(() => ({}));
|
||||
|
||||
return {
|
||||
statusCode: response.status,
|
||||
body: responseBody,
|
||||
headers: Object.fromEntries(response.headers.entries()),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get managed identity token using @azure/identity
|
||||
*/
|
||||
private async getManagedIdentityToken(): Promise<string> {
|
||||
try {
|
||||
// Dynamic import to avoid requiring @azure/identity if not using managed identity
|
||||
const { DefaultAzureCredential } = await import('@azure/identity');
|
||||
const credential = new DefaultAzureCredential({
|
||||
managedIdentityClientId: this.config.managedIdentityClientId,
|
||||
});
|
||||
const token = await credential.getToken('https://logic.azure.com/.default');
|
||||
return token.token;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to get managed identity token: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger workflow for eIDAS verification
|
||||
*/
|
||||
async triggerEIDASVerification(
|
||||
documentId: string,
|
||||
userId: string,
|
||||
eidasProviderUrl: string
|
||||
): Promise<LogicAppsResponse> {
|
||||
return this.triggerWorkflow({
|
||||
triggerName: 'eidas-verification',
|
||||
body: {
|
||||
documentId,
|
||||
userId,
|
||||
eidasProviderUrl,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger workflow for VC issuance via Entra VerifiedID
|
||||
*/
|
||||
async triggerVCIssuance(
|
||||
userId: string,
|
||||
credentialType: string,
|
||||
claims: Record<string, string>
|
||||
): Promise<LogicAppsResponse> {
|
||||
return this.triggerWorkflow({
|
||||
triggerName: 'vc-issuance',
|
||||
body: {
|
||||
userId,
|
||||
credentialType,
|
||||
claims,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger workflow for document processing
|
||||
*/
|
||||
async triggerDocumentProcessing(
|
||||
documentId: string,
|
||||
documentUrl: string,
|
||||
documentType: string
|
||||
): Promise<LogicAppsResponse> {
|
||||
return this.triggerWorkflow({
|
||||
triggerName: 'document-processing',
|
||||
body: {
|
||||
documentId,
|
||||
documentUrl,
|
||||
documentType,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,254 +0,0 @@
|
||||
/**
|
||||
* eIDAS to Microsoft Entra VerifiedID Bridge
|
||||
* Connects eIDAS verification to Microsoft Entra VerifiedID for credential issuance
|
||||
*/
|
||||
|
||||
import { EIDASProvider, EIDASSignature } from './eidas';
|
||||
import { EntraVerifiedIDClient, VerifiableCredentialRequest, ClaimValue } from './entra-verifiedid';
|
||||
import { AzureLogicAppsClient } from './azure-logic-apps';
|
||||
import { validateBase64File, FILE_SIZE_LIMITS, encodeFileToBase64, FileValidationOptions } from './file-utils';
|
||||
|
||||
export interface EIDASToEntraConfig {
|
||||
entraVerifiedID: {
|
||||
tenantId: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
credentialManifestId: string;
|
||||
};
|
||||
eidas: {
|
||||
providerUrl: string;
|
||||
apiKey: string;
|
||||
};
|
||||
logicApps?: {
|
||||
workflowUrl: string;
|
||||
accessKey?: string;
|
||||
managedIdentityClientId?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface EIDASVerificationResult {
|
||||
verified: boolean;
|
||||
eidasSignature?: EIDASSignature;
|
||||
certificateChain?: string[];
|
||||
subject?: string;
|
||||
issuer?: string;
|
||||
validityPeriod?: {
|
||||
notBefore: Date;
|
||||
notAfter: Date;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridge between eIDAS verification and Microsoft Entra VerifiedID issuance
|
||||
*/
|
||||
export class EIDASToEntraBridge {
|
||||
private eidasProvider: EIDASProvider;
|
||||
private entraClient: EntraVerifiedIDClient;
|
||||
private logicAppsClient?: AzureLogicAppsClient;
|
||||
|
||||
constructor(config: EIDASToEntraConfig) {
|
||||
this.eidasProvider = new EIDASProvider({
|
||||
providerUrl: config.eidas.providerUrl,
|
||||
apiKey: config.eidas.apiKey,
|
||||
});
|
||||
|
||||
this.entraClient = new EntraVerifiedIDClient({
|
||||
tenantId: config.entraVerifiedID.tenantId,
|
||||
clientId: config.entraVerifiedID.clientId,
|
||||
clientSecret: config.entraVerifiedID.clientSecret,
|
||||
credentialManifestId: config.entraVerifiedID.credentialManifestId,
|
||||
});
|
||||
|
||||
if (config.logicApps) {
|
||||
this.logicAppsClient = new AzureLogicAppsClient(config.logicApps);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify eIDAS signature and issue credential via Entra VerifiedID
|
||||
*/
|
||||
async verifyAndIssue(
|
||||
document: string | Buffer,
|
||||
userId: string,
|
||||
userEmail: string,
|
||||
pin?: string,
|
||||
validationOptions?: FileValidationOptions
|
||||
): Promise<{
|
||||
verified: boolean;
|
||||
credentialRequest?: {
|
||||
requestId: string;
|
||||
url: string;
|
||||
qrCode?: string;
|
||||
};
|
||||
errors?: string[];
|
||||
}> {
|
||||
// Step 0: Validate and encode document if needed
|
||||
let documentBase64: string;
|
||||
|
||||
if (document instanceof Buffer) {
|
||||
// Encode buffer to base64
|
||||
documentBase64 = encodeFileToBase64(document);
|
||||
} else {
|
||||
// Validate base64 string
|
||||
const validation = validateBase64File(
|
||||
document as string,
|
||||
validationOptions || {
|
||||
maxSize: FILE_SIZE_LIMITS.MEDIUM,
|
||||
allowedMimeTypes: [
|
||||
'application/pdf',
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'application/json',
|
||||
'text/plain',
|
||||
],
|
||||
}
|
||||
);
|
||||
|
||||
if (!validation.valid) {
|
||||
return {
|
||||
verified: false,
|
||||
errors: validation.errors,
|
||||
};
|
||||
}
|
||||
|
||||
documentBase64 = document as string;
|
||||
}
|
||||
|
||||
// Step 1: Request eIDAS signature
|
||||
let eidasSignature: EIDASSignature;
|
||||
try {
|
||||
eidasSignature = await this.eidasProvider.requestSignature(documentBase64);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.error('eIDAS signature request failed:', errorMessage);
|
||||
return {
|
||||
verified: false,
|
||||
errors: [`eIDAS signature request failed: ${errorMessage}`],
|
||||
};
|
||||
}
|
||||
|
||||
// Step 2: Verify eIDAS signature
|
||||
const verified = await this.eidasProvider.verifySignature(eidasSignature);
|
||||
if (!verified) {
|
||||
return {
|
||||
verified: false,
|
||||
errors: ['eIDAS signature verification failed'],
|
||||
};
|
||||
}
|
||||
|
||||
// Step 3: Trigger Logic App workflow if configured
|
||||
if (this.logicAppsClient) {
|
||||
try {
|
||||
const documentId = document instanceof Buffer ? document.toString('base64').substring(0, 100) : (document as string).substring(0, 100);
|
||||
await this.logicAppsClient.triggerEIDASVerification(
|
||||
documentId,
|
||||
userId,
|
||||
this.eidasProvider['config'].providerUrl
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn('Logic App trigger failed (non-blocking):', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: Issue credential via Entra VerifiedID
|
||||
const credentialRequest: VerifiableCredentialRequest = {
|
||||
claims: {
|
||||
email: userEmail,
|
||||
userId,
|
||||
eidasVerified: true, // Boolean value (will be converted to string)
|
||||
eidasCertificate: eidasSignature.certificate,
|
||||
eidasSignatureTimestamp: eidasSignature.timestamp.toISOString(),
|
||||
},
|
||||
pin,
|
||||
};
|
||||
|
||||
try {
|
||||
const credentialResponse = await this.entraClient.issueCredential(credentialRequest);
|
||||
|
||||
return {
|
||||
verified: true,
|
||||
credentialRequest: {
|
||||
requestId: credentialResponse.requestId,
|
||||
url: credentialResponse.url,
|
||||
qrCode: credentialResponse.qrCode,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Entra VerifiedID credential issuance failed:', error);
|
||||
return { verified: true }; // eIDAS verified but credential issuance failed
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify eIDAS signature only (without issuing credential)
|
||||
*/
|
||||
async verifyEIDAS(document: string): Promise<EIDASVerificationResult> {
|
||||
try {
|
||||
const signature = await this.eidasProvider.requestSignature(document);
|
||||
const verified = await this.eidasProvider.verifySignature(signature);
|
||||
|
||||
if (!verified) {
|
||||
return { verified: false };
|
||||
}
|
||||
|
||||
// Extract certificate information (simplified - in production parse certificate)
|
||||
return {
|
||||
verified: true,
|
||||
eidasSignature: signature,
|
||||
subject: 'eIDAS Subject', // Would be extracted from certificate
|
||||
issuer: 'eIDAS Issuer', // Would be extracted from certificate
|
||||
validityPeriod: {
|
||||
notBefore: signature.timestamp,
|
||||
notAfter: new Date(signature.timestamp.getTime() + 365 * 24 * 60 * 60 * 1000), // 1 year default
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('eIDAS verification failed:', error);
|
||||
return { verified: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue credential based on verified eIDAS signature
|
||||
*/
|
||||
async issueCredentialFromEIDAS(
|
||||
eidasVerificationResult: EIDASVerificationResult,
|
||||
userId: string,
|
||||
userEmail: string,
|
||||
additionalClaims?: Record<string, ClaimValue>,
|
||||
pin?: string
|
||||
): Promise<{
|
||||
requestId: string;
|
||||
url: string;
|
||||
qrCode?: string;
|
||||
}> {
|
||||
if (!eidasVerificationResult.verified || !eidasVerificationResult.eidasSignature) {
|
||||
throw new Error('eIDAS verification must be successful before issuing credential');
|
||||
}
|
||||
|
||||
const claims: Record<string, ClaimValue> = {
|
||||
email: userEmail,
|
||||
userId,
|
||||
eidasVerified: true, // Boolean value (will be converted to string)
|
||||
eidasCertificate: eidasVerificationResult.eidasSignature.certificate,
|
||||
eidasSignatureTimestamp: eidasVerificationResult.eidasSignature.timestamp.toISOString(),
|
||||
...additionalClaims,
|
||||
};
|
||||
|
||||
if (eidasVerificationResult.subject) {
|
||||
claims.eidasSubject = eidasVerificationResult.subject;
|
||||
}
|
||||
|
||||
if (eidasVerificationResult.issuer) {
|
||||
claims.eidasIssuer = eidasVerificationResult.issuer;
|
||||
}
|
||||
|
||||
const credentialRequest: VerifiableCredentialRequest = {
|
||||
claims,
|
||||
pin,
|
||||
};
|
||||
|
||||
return await this.entraClient.issueCredential(credentialRequest);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
/**
|
||||
* Credential Image/Logo Management for Entra VerifiedID
|
||||
* Handles image conversion and validation for credential display
|
||||
*/
|
||||
|
||||
export interface CredentialImageConfig {
|
||||
logoUri?: string;
|
||||
backgroundColor?: string;
|
||||
textColor?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface ImageFormat {
|
||||
format: 'svg' | 'png' | 'jpg' | 'jpeg' | 'bmp';
|
||||
data: string | Buffer;
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Supported image formats for Entra VerifiedID
|
||||
* Note: Entra VerifiedID officially supports PNG, JPG, BMP
|
||||
* SVG may work but PNG is recommended for compatibility
|
||||
*/
|
||||
export const SUPPORTED_FORMATS = ['png', 'jpg', 'jpeg', 'bmp', 'svg'] as const;
|
||||
export type SupportedFormat = typeof SUPPORTED_FORMATS[number];
|
||||
|
||||
/**
|
||||
* Validate image format
|
||||
*/
|
||||
export function validateImageFormat(format: string): format is SupportedFormat {
|
||||
return SUPPORTED_FORMATS.includes(format.toLowerCase() as SupportedFormat);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get MIME type for image format
|
||||
*/
|
||||
export function getImageMimeType(format: SupportedFormat): string {
|
||||
const mimeTypes: Record<SupportedFormat, string> = {
|
||||
svg: 'image/svg+xml',
|
||||
png: 'image/png',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
bmp: 'image/bmp',
|
||||
};
|
||||
return mimeTypes[format] || 'image/png';
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert SVG to PNG (if needed for Entra compatibility)
|
||||
* Note: This requires additional dependencies like sharp or svg2png
|
||||
*/
|
||||
export async function convertSvgToPng(
|
||||
svgData: string | Buffer,
|
||||
width: number = 200,
|
||||
height: number = 200
|
||||
): Promise<Buffer> {
|
||||
// Check if sharp is available (optional dependency)
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const sharp = require('sharp');
|
||||
|
||||
const svgBuffer = typeof svgData === 'string' ? Buffer.from(svgData) : svgData;
|
||||
return await sharp(svgBuffer)
|
||||
.resize(width, height)
|
||||
.png()
|
||||
.toBuffer();
|
||||
} catch (error) {
|
||||
// If sharp is not available, return original SVG
|
||||
// Note: Entra may accept SVG, but PNG is recommended
|
||||
console.warn('sharp not available, using SVG directly (may not be supported by Entra)');
|
||||
return typeof svgData === 'string' ? Buffer.from(svgData) : svgData;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare image for Entra VerifiedID
|
||||
* Converts SVG to PNG if needed, validates format
|
||||
*/
|
||||
export async function prepareCredentialImage(
|
||||
imageData: string | Buffer,
|
||||
format?: SupportedFormat
|
||||
): Promise<{
|
||||
data: Buffer;
|
||||
mimeType: string;
|
||||
format: SupportedFormat;
|
||||
}> {
|
||||
let imageBuffer: Buffer;
|
||||
let detectedFormat: SupportedFormat;
|
||||
let mimeType: string;
|
||||
|
||||
// Detect format if not provided
|
||||
if (!format) {
|
||||
if (typeof imageData === 'string') {
|
||||
// Check if it's a data URL
|
||||
if (imageData.startsWith('data:')) {
|
||||
const match = imageData.match(/data:image\/([^;]+)/);
|
||||
detectedFormat = (match?.[1]?.toLowerCase() || 'png') as SupportedFormat;
|
||||
} else if (imageData.trim().startsWith('<svg')) {
|
||||
detectedFormat = 'svg';
|
||||
} else {
|
||||
detectedFormat = 'png'; // Default
|
||||
}
|
||||
} else {
|
||||
// Try to detect from buffer (basic check)
|
||||
const header = imageData.toString('hex', 0, 4);
|
||||
if (header.startsWith('89504e47')) {
|
||||
detectedFormat = 'png';
|
||||
} else if (header.startsWith('ffd8ff')) {
|
||||
detectedFormat = 'jpg';
|
||||
} else if (header.startsWith('424d')) {
|
||||
detectedFormat = 'bmp';
|
||||
} else {
|
||||
detectedFormat = 'png'; // Default
|
||||
}
|
||||
}
|
||||
} else {
|
||||
detectedFormat = format;
|
||||
}
|
||||
|
||||
// Convert to buffer if string
|
||||
if (typeof imageData === 'string') {
|
||||
if (imageData.startsWith('data:')) {
|
||||
// Extract base64 data
|
||||
const base64Data = imageData.split(',')[1];
|
||||
if (!base64Data) {
|
||||
throw new Error('Invalid image data URL: missing base64 payload');
|
||||
}
|
||||
imageBuffer = Buffer.from(base64Data, 'base64');
|
||||
} else {
|
||||
imageBuffer = Buffer.from(imageData);
|
||||
}
|
||||
} else {
|
||||
imageBuffer = imageData;
|
||||
}
|
||||
|
||||
// Convert SVG to PNG for Entra compatibility
|
||||
if (detectedFormat === 'svg') {
|
||||
try {
|
||||
imageBuffer = await convertSvgToPng(imageBuffer);
|
||||
detectedFormat = 'png';
|
||||
mimeType = 'image/png';
|
||||
} catch (error) {
|
||||
console.warn('SVG to PNG conversion failed, using SVG (may not be supported)', error);
|
||||
mimeType = 'image/svg+xml';
|
||||
}
|
||||
} else {
|
||||
mimeType = getImageMimeType(detectedFormat);
|
||||
}
|
||||
|
||||
// Validate format
|
||||
if (!validateImageFormat(detectedFormat)) {
|
||||
throw new Error(`Unsupported image format: ${detectedFormat}. Supported: ${SUPPORTED_FORMATS.join(', ')}`);
|
||||
}
|
||||
|
||||
return {
|
||||
data: imageBuffer,
|
||||
mimeType,
|
||||
format: detectedFormat,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create data URL from image
|
||||
*/
|
||||
export function createImageDataUrl(imageData: Buffer, mimeType: string): string {
|
||||
const base64 = imageData.toString('base64');
|
||||
return `data:${mimeType};base64,${base64}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recommended image specifications for Entra VerifiedID
|
||||
*/
|
||||
export function getRecommendedImageSpecs(): {
|
||||
format: 'png' | 'jpg';
|
||||
width: number;
|
||||
height: number;
|
||||
maxSizeKB: number;
|
||||
} {
|
||||
return {
|
||||
format: 'png', // Recommended format
|
||||
width: 200,
|
||||
height: 200,
|
||||
maxSizeKB: 100, // Max 100KB recommended
|
||||
};
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
/**
|
||||
* Enhanced Microsoft Entra VerifiedID connector
|
||||
* Adds retry logic, multi-manifest support, and improved error handling
|
||||
*/
|
||||
|
||||
import { EntraVerifiedIDClient, EntraVerifiedIDConfig, VerifiableCredentialRequest, VerifiableCredentialResponse, VerifiableCredentialStatus, VerifiedCredential } from './entra-verifiedid';
|
||||
|
||||
export interface RetryConfig {
|
||||
maxRetries?: number;
|
||||
initialDelayMs?: number;
|
||||
maxDelayMs?: number;
|
||||
backoffMultiplier?: number;
|
||||
retryableStatusCodes?: number[];
|
||||
}
|
||||
|
||||
export interface MultiManifestConfig extends EntraVerifiedIDConfig {
|
||||
manifests?: Record<string, string>; // manifest name -> manifest ID mapping
|
||||
}
|
||||
|
||||
const DEFAULT_RETRY_CONFIG: Required<RetryConfig> = {
|
||||
maxRetries: 3,
|
||||
initialDelayMs: 1000,
|
||||
maxDelayMs: 10000,
|
||||
backoffMultiplier: 2,
|
||||
retryableStatusCodes: [429, 500, 502, 503, 504],
|
||||
};
|
||||
|
||||
/**
|
||||
* Sleep utility for retry delays
|
||||
*/
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error is retryable
|
||||
*/
|
||||
function isRetryableError(statusCode: number, retryableStatusCodes: number[]): boolean {
|
||||
return retryableStatusCodes.includes(statusCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced Entra VerifiedID client with retry logic and multi-manifest support
|
||||
*/
|
||||
export class EnhancedEntraVerifiedIDClient extends EntraVerifiedIDClient {
|
||||
private retryConfig: Required<RetryConfig>;
|
||||
private manifests: Record<string, string>;
|
||||
|
||||
constructor(config: MultiManifestConfig, retryConfig?: RetryConfig) {
|
||||
super(config);
|
||||
this.retryConfig = { ...DEFAULT_RETRY_CONFIG, ...retryConfig };
|
||||
this.manifests = config.manifests || {};
|
||||
// Add default manifest if provided
|
||||
if (config.credentialManifestId) {
|
||||
this.manifests['default'] = config.credentialManifestId;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get manifest ID by name, fallback to default
|
||||
*/
|
||||
private getManifestId(manifestName?: string): string {
|
||||
if (manifestName && this.manifests[manifestName]) {
|
||||
return this.manifests[manifestName];
|
||||
}
|
||||
if (this.manifests['default']) {
|
||||
return this.manifests['default'];
|
||||
}
|
||||
throw new Error('No credential manifest ID configured');
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a request with retry logic
|
||||
*/
|
||||
private async executeWithRetry<T>(
|
||||
operation: () => Promise<T>,
|
||||
operationName: string
|
||||
): Promise<T> {
|
||||
let lastError: Error | null = null;
|
||||
let delay = this.retryConfig.initialDelayMs;
|
||||
|
||||
for (let attempt = 0; attempt <= this.retryConfig.maxRetries; attempt++) {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error));
|
||||
|
||||
// Check if error is retryable
|
||||
const statusCode = (error as any)?.statusCode || (error as any)?.response?.status;
|
||||
const isRetryable = statusCode && isRetryableError(statusCode, this.retryConfig.retryableStatusCodes);
|
||||
|
||||
// Don't retry on last attempt or if error is not retryable
|
||||
if (attempt === this.retryConfig.maxRetries || !isRetryable) {
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
// Wait before retrying
|
||||
await sleep(Math.min(delay, this.retryConfig.maxDelayMs));
|
||||
delay *= this.retryConfig.backoffMultiplier;
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error(`${operationName} failed after ${this.retryConfig.maxRetries} retries`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue credential with retry logic and manifest selection
|
||||
*/
|
||||
async issueCredential(
|
||||
request: VerifiableCredentialRequest & { manifestName?: string }
|
||||
): Promise<VerifiableCredentialResponse> {
|
||||
const manifestId = this.getManifestId(request.manifestName);
|
||||
|
||||
// Create a modified request without manifestName
|
||||
const { manifestName, ...credentialRequest } = request;
|
||||
|
||||
// Temporarily set manifest ID for this request
|
||||
const originalManifestId = (this as any).config.credentialManifestId;
|
||||
(this as any).config.credentialManifestId = manifestId;
|
||||
|
||||
try {
|
||||
return await this.executeWithRetry(
|
||||
() => super.issueCredential(credentialRequest),
|
||||
'issueCredential'
|
||||
);
|
||||
} finally {
|
||||
// Restore original manifest ID
|
||||
(this as any).config.credentialManifestId = originalManifestId;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get issuance status with retry logic
|
||||
*/
|
||||
async getIssuanceStatus(requestId: string): Promise<VerifiableCredentialStatus> {
|
||||
return this.executeWithRetry(
|
||||
() => super.getIssuanceStatus(requestId),
|
||||
'getIssuanceStatus'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify credential with retry logic
|
||||
*/
|
||||
async verifyCredential(credential: VerifiedCredential): Promise<boolean> {
|
||||
return this.executeWithRetry(
|
||||
() => super.verifyCredential(credential),
|
||||
'verifyCredential'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create presentation request with retry logic and manifest selection
|
||||
*/
|
||||
async createPresentationRequest(
|
||||
manifestName?: string,
|
||||
callbackUrl?: string
|
||||
): Promise<VerifiableCredentialResponse> {
|
||||
const manifestId = this.getManifestId(manifestName);
|
||||
return this.executeWithRetry(
|
||||
() => super.createPresentationRequest(manifestId, callbackUrl),
|
||||
'createPresentationRequest'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new manifest
|
||||
*/
|
||||
registerManifest(name: string, manifestId: string): void {
|
||||
this.manifests[name] = manifestId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered manifests
|
||||
*/
|
||||
getManifests(): Record<string, string> {
|
||||
return { ...this.manifests };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
/**
|
||||
* Entra VerifiedID Integration Tests
|
||||
* These tests require actual Entra VerifiedID configuration
|
||||
* Set ENTRA_TEST_* environment variables to run
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import { EnhancedEntraVerifiedIDClient } from './entra-verifiedid-enhanced';
|
||||
import { getEnv } from '@the-order/shared';
|
||||
|
||||
describe('Entra VerifiedID Integration Tests', () => {
|
||||
let client: EnhancedEntraVerifiedIDClient | null = null;
|
||||
|
||||
beforeAll(() => {
|
||||
const env = getEnv();
|
||||
|
||||
if (
|
||||
!env.ENTRA_TENANT_ID ||
|
||||
!env.ENTRA_CLIENT_ID ||
|
||||
!env.ENTRA_CLIENT_SECRET ||
|
||||
!env.ENTRA_CREDENTIAL_MANIFEST_ID
|
||||
) {
|
||||
console.warn('Entra VerifiedID credentials not configured, skipping integration tests');
|
||||
return;
|
||||
}
|
||||
|
||||
client = new EnhancedEntraVerifiedIDClient({
|
||||
tenantId: env.ENTRA_TENANT_ID,
|
||||
clientId: env.ENTRA_CLIENT_ID,
|
||||
clientSecret: env.ENTRA_CLIENT_SECRET,
|
||||
credentialManifestId: env.ENTRA_CREDENTIAL_MANIFEST_ID,
|
||||
});
|
||||
});
|
||||
|
||||
it('should issue a credential', async () => {
|
||||
if (!client) {
|
||||
return; // Skip if not configured
|
||||
}
|
||||
|
||||
const request = {
|
||||
claims: {
|
||||
email: '[email protected]',
|
||||
name: 'Test User',
|
||||
test: 'true',
|
||||
},
|
||||
};
|
||||
|
||||
const response = await client.issueCredential(request);
|
||||
|
||||
expect(response).toBeDefined();
|
||||
expect(response.requestId).toBeDefined();
|
||||
expect(response.url).toBeDefined();
|
||||
expect(response.expiry).toBeGreaterThan(0);
|
||||
}, 30000); // 30 second timeout
|
||||
|
||||
it('should check issuance status', async () => {
|
||||
if (!client) {
|
||||
return; // Skip if not configured
|
||||
}
|
||||
|
||||
// First issue a credential
|
||||
const issueResponse = await client.issueCredential({
|
||||
claims: { email: '[email protected]' },
|
||||
});
|
||||
|
||||
// Then check status
|
||||
const status = await client.getIssuanceStatus(issueResponse.requestId);
|
||||
|
||||
expect(status).toBeDefined();
|
||||
expect(status.requestId).toBe(issueResponse.requestId);
|
||||
expect(['request_created', 'request_retrieved', 'issuance_successful', 'issuance_failed']).toContain(status.state);
|
||||
}, 30000);
|
||||
|
||||
it('should support multi-manifest', async () => {
|
||||
if (!client) {
|
||||
return; // Skip if not configured
|
||||
}
|
||||
|
||||
// Register additional manifest
|
||||
client.registerManifest('test', 'test-manifest-id');
|
||||
|
||||
const manifests = client.getManifests();
|
||||
expect(manifests.test).toBe('test-manifest-id');
|
||||
});
|
||||
|
||||
it('should handle retries on transient errors', async () => {
|
||||
if (!client) {
|
||||
return; // Skip if not configured
|
||||
}
|
||||
|
||||
// This test would require mocking or simulating transient errors
|
||||
// For now, we just verify retry config is set
|
||||
expect(client).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,371 +0,0 @@
|
||||
/**
|
||||
* Entra VerifiedID Client Tests
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { EntraVerifiedIDClient, VerifiableCredentialRequest, VerifiedCredential } from './entra-verifiedid';
|
||||
import { EnhancedEntraVerifiedIDClient } from './entra-verifiedid-enhanced';
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
vi.mock('node-fetch');
|
||||
|
||||
describe('EntraVerifiedIDClient', () => {
|
||||
let client: EntraVerifiedIDClient;
|
||||
const config = {
|
||||
tenantId: 'test-tenant-id',
|
||||
clientId: 'test-client-id',
|
||||
clientSecret: 'test-client-secret',
|
||||
credentialManifestId: 'test-manifest-id',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
client = new EntraVerifiedIDClient(config);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('getAccessToken', () => {
|
||||
it('should cache access token until expiry', async () => {
|
||||
const mockTokenResponse = {
|
||||
access_token: 'test-token',
|
||||
expires_in: 3600,
|
||||
};
|
||||
|
||||
(fetch as any).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => mockTokenResponse,
|
||||
});
|
||||
|
||||
// First call
|
||||
const token1 = await (client as any).getAccessToken();
|
||||
expect(token1).toBe('test-token');
|
||||
|
||||
// Second call should use cached token
|
||||
const token2 = await (client as any).getAccessToken();
|
||||
expect(token2).toBe('test-token');
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should refresh token when expired', async () => {
|
||||
const mockTokenResponse = {
|
||||
access_token: 'test-token',
|
||||
expires_in: 1, // 1 second expiry
|
||||
};
|
||||
|
||||
(fetch as any).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockTokenResponse,
|
||||
});
|
||||
|
||||
await (client as any).getAccessToken();
|
||||
|
||||
// Wait for token to expire
|
||||
await new Promise((resolve) => setTimeout(resolve, 1100));
|
||||
|
||||
// Should fetch new token
|
||||
await (client as any).getAccessToken();
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateCredentialRequest', () => {
|
||||
it('should validate request with valid claims', () => {
|
||||
const request: VerifiableCredentialRequest = {
|
||||
claims: { email: '[email protected]', name: 'Test User' },
|
||||
};
|
||||
expect(() => (client as any).validateCredentialRequest(request)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should reject request with empty claims', () => {
|
||||
const request: VerifiableCredentialRequest = {
|
||||
claims: {},
|
||||
};
|
||||
expect(() => (client as any).validateCredentialRequest(request)).toThrow('At least one claim is required');
|
||||
});
|
||||
|
||||
it('should validate PIN format', () => {
|
||||
const validRequest: VerifiableCredentialRequest = {
|
||||
claims: { email: '[email protected]' },
|
||||
pin: '1234',
|
||||
};
|
||||
expect(() => (client as any).validateCredentialRequest(validRequest)).not.toThrow();
|
||||
|
||||
const invalidRequest: VerifiableCredentialRequest = {
|
||||
claims: { email: '[email protected]' },
|
||||
pin: 'abc',
|
||||
};
|
||||
expect(() => (client as any).validateCredentialRequest(invalidRequest)).toThrow('PIN must be between 4 and 8 characters');
|
||||
});
|
||||
});
|
||||
|
||||
describe('issueCredential', () => {
|
||||
it('should issue credential successfully', async () => {
|
||||
const mockTokenResponse = {
|
||||
access_token: 'test-token',
|
||||
expires_in: 3600,
|
||||
};
|
||||
|
||||
const mockIssuanceResponse = {
|
||||
requestId: 'test-request-id',
|
||||
url: 'https://verifiedid.did.msidentity.com/issuance',
|
||||
expiry: Date.now() + 3600000,
|
||||
qrCode: 'data:image/png;base64,test',
|
||||
};
|
||||
|
||||
(fetch as any)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => mockTokenResponse,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => mockIssuanceResponse,
|
||||
});
|
||||
|
||||
const request: VerifiableCredentialRequest = {
|
||||
claims: { email: '[email protected]', name: 'Test User' },
|
||||
};
|
||||
|
||||
const result = await client.issueCredential(request);
|
||||
expect(result.requestId).toBe('test-request-id');
|
||||
expect(result.url).toBe('https://verifiedid.did.msidentity.com/issuance');
|
||||
expect(result.qrCode).toBe('data:image/png;base64,test');
|
||||
});
|
||||
|
||||
it('should throw error when manifest ID is missing', async () => {
|
||||
const clientWithoutManifest = new EntraVerifiedIDClient({
|
||||
tenantId: 'test-tenant',
|
||||
clientId: 'test-client',
|
||||
clientSecret: 'test-secret',
|
||||
});
|
||||
|
||||
const request: VerifiableCredentialRequest = {
|
||||
claims: { email: '[email protected]' },
|
||||
};
|
||||
|
||||
await expect(clientWithoutManifest.issueCredential(request)).rejects.toThrow('Credential manifest ID is required');
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyCredential', () => {
|
||||
it('should verify credential successfully', async () => {
|
||||
const mockTokenResponse = {
|
||||
access_token: 'test-token',
|
||||
expires_in: 3600,
|
||||
};
|
||||
|
||||
const mockVerifyResponse = {
|
||||
verified: true,
|
||||
};
|
||||
|
||||
(fetch as any)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => mockTokenResponse,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => mockVerifyResponse,
|
||||
});
|
||||
|
||||
const credential: VerifiedCredential = {
|
||||
id: 'test-credential-id',
|
||||
type: ['VerifiableCredential'],
|
||||
issuer: 'did:web:test.verifiedid.msidentity.com',
|
||||
issuanceDate: new Date().toISOString(),
|
||||
credentialSubject: { email: '[email protected]' },
|
||||
proof: {
|
||||
type: 'JsonWebSignature2020',
|
||||
created: new Date().toISOString(),
|
||||
proofPurpose: 'assertionMethod',
|
||||
verificationMethod: 'did:web:test#key',
|
||||
jws: 'test-jws',
|
||||
},
|
||||
};
|
||||
|
||||
const result = await client.verifyCredential(credential);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject invalid credential structure', async () => {
|
||||
const invalidCredential = {
|
||||
id: 'test-id',
|
||||
} as VerifiedCredential;
|
||||
|
||||
await expect(client.verifyCredential(invalidCredential)).rejects.toThrow('Credential type is required');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('EnhancedEntraVerifiedIDClient', () => {
|
||||
let client: EnhancedEntraVerifiedIDClient;
|
||||
const config = {
|
||||
tenantId: 'test-tenant-id',
|
||||
clientId: 'test-client-id',
|
||||
clientSecret: 'test-client-secret',
|
||||
credentialManifestId: 'default-manifest-id',
|
||||
manifests: {
|
||||
default: 'default-manifest-id',
|
||||
diplomatic: 'diplomatic-manifest-id',
|
||||
judicial: 'judicial-manifest-id',
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
client = new EnhancedEntraVerifiedIDClient(config);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('multi-manifest support', () => {
|
||||
it('should use default manifest when no manifest name provided', async () => {
|
||||
const mockTokenResponse = {
|
||||
access_token: 'test-token',
|
||||
expires_in: 3600,
|
||||
};
|
||||
|
||||
const mockIssuanceResponse = {
|
||||
requestId: 'test-request-id',
|
||||
url: 'https://verifiedid.did.msidentity.com/issuance',
|
||||
expiry: Date.now() + 3600000,
|
||||
};
|
||||
|
||||
(fetch as any)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => mockTokenResponse,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => mockIssuanceResponse,
|
||||
});
|
||||
|
||||
const request: VerifiableCredentialRequest = {
|
||||
claims: { email: '[email protected]' },
|
||||
};
|
||||
|
||||
await client.issueCredential(request);
|
||||
|
||||
// Verify the request used default manifest
|
||||
const fetchCalls = (fetch as any).mock.calls;
|
||||
const issuanceCall = fetchCalls.find((call: any[]) =>
|
||||
call[0]?.includes('createIssuanceRequest')
|
||||
);
|
||||
expect(issuanceCall).toBeDefined();
|
||||
});
|
||||
|
||||
it('should use specified manifest when manifest name provided', async () => {
|
||||
const mockTokenResponse = {
|
||||
access_token: 'test-token',
|
||||
expires_in: 3600,
|
||||
};
|
||||
|
||||
const mockIssuanceResponse = {
|
||||
requestId: 'test-request-id',
|
||||
url: 'https://verifiedid.did.msidentity.com/issuance',
|
||||
expiry: Date.now() + 3600000,
|
||||
};
|
||||
|
||||
(fetch as any)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => mockTokenResponse,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => mockIssuanceResponse,
|
||||
});
|
||||
|
||||
const request: VerifiableCredentialRequest & { manifestName?: string } = {
|
||||
claims: { email: '[email protected]' },
|
||||
manifestName: 'diplomatic',
|
||||
};
|
||||
|
||||
await client.issueCredential(request);
|
||||
|
||||
// Verify the request used diplomatic manifest
|
||||
const fetchCalls = (fetch as any).mock.calls;
|
||||
const issuanceCall = fetchCalls.find((call: any[]) =>
|
||||
call[0]?.includes('createIssuanceRequest')
|
||||
);
|
||||
expect(issuanceCall).toBeDefined();
|
||||
});
|
||||
|
||||
it('should allow registering new manifests', () => {
|
||||
client.registerManifest('financial', 'financial-manifest-id');
|
||||
const manifests = client.getManifests();
|
||||
expect(manifests.financial).toBe('financial-manifest-id');
|
||||
});
|
||||
});
|
||||
|
||||
describe('retry logic', () => {
|
||||
it('should retry on retryable errors', async () => {
|
||||
const mockTokenResponse = {
|
||||
access_token: 'test-token',
|
||||
expires_in: 3600,
|
||||
};
|
||||
|
||||
// First two calls fail with 500, third succeeds
|
||||
(fetch as any)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => mockTokenResponse,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: async () => 'Internal Server Error',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => mockTokenResponse,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
requestId: 'test-request-id',
|
||||
url: 'https://verifiedid.did.msidentity.com/issuance',
|
||||
expiry: Date.now() + 3600000,
|
||||
}),
|
||||
});
|
||||
|
||||
const request: VerifiableCredentialRequest = {
|
||||
claims: { email: '[email protected]' },
|
||||
};
|
||||
|
||||
const result = await client.issueCredential(request);
|
||||
expect(result.requestId).toBe('test-request-id');
|
||||
// Should have retried (more than 2 fetch calls)
|
||||
expect(fetch).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it('should not retry on non-retryable errors', async () => {
|
||||
const mockTokenResponse = {
|
||||
access_token: 'test-token',
|
||||
expires_in: 3600,
|
||||
};
|
||||
|
||||
(fetch as any)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => mockTokenResponse,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 400,
|
||||
text: async () => 'Bad Request',
|
||||
});
|
||||
|
||||
const request: VerifiableCredentialRequest = {
|
||||
claims: { email: '[email protected]' },
|
||||
};
|
||||
|
||||
await expect(client.issueCredential(request)).rejects.toThrow();
|
||||
// Should not retry (only 2 fetch calls)
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,406 +0,0 @@
|
||||
/**
|
||||
* Microsoft Entra VerifiedID connector
|
||||
* Provides integration with Microsoft Entra VerifiedID for verifiable credential issuance and verification
|
||||
*/
|
||||
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
export interface EntraVerifiedIDConfig {
|
||||
tenantId: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
credentialManifestId?: string;
|
||||
apiVersion?: string;
|
||||
logoUri?: string; // URI to credential logo/image (PNG, JPG, BMP recommended; SVG may work)
|
||||
backgroundColor?: string; // Background color for credential card
|
||||
textColor?: string; // Text color for credential card
|
||||
}
|
||||
|
||||
/**
|
||||
* Supported claim value types
|
||||
*/
|
||||
export type ClaimValue = string | number | boolean | null;
|
||||
|
||||
/**
|
||||
* Verifiable credential request with enhanced claim types
|
||||
*/
|
||||
export interface VerifiableCredentialRequest {
|
||||
claims: Record<string, ClaimValue>;
|
||||
pin?: string;
|
||||
callbackUrl?: string;
|
||||
}
|
||||
|
||||
export interface VerifiableCredentialResponse {
|
||||
requestId: string;
|
||||
url: string;
|
||||
expiry: number;
|
||||
qrCode?: string;
|
||||
}
|
||||
|
||||
export interface VerifiableCredentialStatus {
|
||||
requestId: string;
|
||||
state: 'request_created' | 'request_retrieved' | 'issuance_successful' | 'issuance_failed';
|
||||
code?: string;
|
||||
error?: {
|
||||
code: string;
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface VerifiedCredential {
|
||||
id: string;
|
||||
type: string[];
|
||||
issuer: string;
|
||||
issuanceDate: string;
|
||||
expirationDate?: string;
|
||||
credentialSubject: Record<string, unknown>;
|
||||
proof: {
|
||||
type: string;
|
||||
created: string;
|
||||
proofPurpose: string;
|
||||
verificationMethod: string;
|
||||
jws: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Microsoft Entra VerifiedID client
|
||||
*/
|
||||
export class EntraVerifiedIDClient {
|
||||
private accessToken: string | null = null;
|
||||
private tokenExpiry: number = 0;
|
||||
private baseUrl: string;
|
||||
|
||||
constructor(private config: EntraVerifiedIDConfig) {
|
||||
this.baseUrl = `https://verifiedid.did.msidentity.com/v1.0/${config.tenantId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get access token for Microsoft Entra VerifiedID API
|
||||
*/
|
||||
private async getAccessToken(): Promise<string> {
|
||||
// Check if we have a valid cached token
|
||||
if (this.accessToken && Date.now() < this.tokenExpiry) {
|
||||
return this.accessToken;
|
||||
}
|
||||
|
||||
const tokenUrl = `https://login.microsoftonline.com/${this.config.tenantId}/oauth2/v2.0/token`;
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: this.config.clientId,
|
||||
client_secret: this.config.clientSecret,
|
||||
scope: 'https://verifiedid.did.msidentity.com/.default',
|
||||
grant_type: 'client_credentials',
|
||||
});
|
||||
|
||||
const response = await fetch(tokenUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: params.toString(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Failed to get access token: ${response.status} ${errorText}`);
|
||||
}
|
||||
|
||||
const tokenData = (await response.json()) as {
|
||||
access_token: string;
|
||||
expires_in: number;
|
||||
};
|
||||
|
||||
this.accessToken = tokenData.access_token;
|
||||
// Set expiry 5 minutes before actual expiry for safety
|
||||
const expiresIn = typeof tokenData.expires_in === 'number' ? tokenData.expires_in : 3600;
|
||||
this.tokenExpiry = Date.now() + (expiresIn - 300) * 1000;
|
||||
|
||||
return this.accessToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate credential request
|
||||
*/
|
||||
private validateCredentialRequest(request: VerifiableCredentialRequest): void {
|
||||
if (!request.claims || Object.keys(request.claims).length === 0) {
|
||||
throw new Error('At least one claim is required');
|
||||
}
|
||||
|
||||
// Validate claim keys
|
||||
for (const key of Object.keys(request.claims)) {
|
||||
if (!key || key.trim().length === 0) {
|
||||
throw new Error('Claim keys cannot be empty');
|
||||
}
|
||||
if (key.length > 100) {
|
||||
throw new Error(`Claim key "${key}" exceeds maximum length of 100 characters`);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate PIN if provided
|
||||
if (request.pin) {
|
||||
if (request.pin.length < 4 || request.pin.length > 8) {
|
||||
throw new Error('PIN must be between 4 and 8 characters');
|
||||
}
|
||||
if (!/^\d+$/.test(request.pin)) {
|
||||
throw new Error('PIN must contain only digits');
|
||||
}
|
||||
}
|
||||
|
||||
// Validate callback URL if provided
|
||||
if (request.callbackUrl) {
|
||||
try {
|
||||
new URL(request.callbackUrl);
|
||||
} catch {
|
||||
throw new Error('Invalid callback URL format');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue a verifiable credential
|
||||
*/
|
||||
async issueCredential(
|
||||
request: VerifiableCredentialRequest
|
||||
): Promise<VerifiableCredentialResponse> {
|
||||
// Validate request
|
||||
this.validateCredentialRequest(request);
|
||||
|
||||
const token = await this.getAccessToken();
|
||||
const manifestId = this.config.credentialManifestId;
|
||||
|
||||
if (!manifestId) {
|
||||
throw new Error('Credential manifest ID is required for issuance');
|
||||
}
|
||||
|
||||
const issueUrl = `${this.baseUrl}/verifiableCredentials/createIssuanceRequest`;
|
||||
|
||||
// Convert claims to string format (Entra VerifiedID requires string values)
|
||||
const stringClaims: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(request.claims)) {
|
||||
if (value === null) {
|
||||
stringClaims[key] = '';
|
||||
} else if (typeof value === 'boolean') {
|
||||
stringClaims[key] = value.toString();
|
||||
} else if (typeof value === 'number') {
|
||||
stringClaims[key] = value.toString();
|
||||
} else {
|
||||
stringClaims[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
const requestBody: Record<string, unknown> = {
|
||||
includeQRCode: true,
|
||||
callback: request.callbackUrl
|
||||
? {
|
||||
url: request.callbackUrl,
|
||||
state: crypto.randomUUID(),
|
||||
}
|
||||
: undefined,
|
||||
authority: `did:web:${this.config.tenantId}.verifiedid.msidentity.com`,
|
||||
registration: {
|
||||
clientName: 'The Order',
|
||||
},
|
||||
type: manifestId,
|
||||
manifestId,
|
||||
pin: request.pin
|
||||
? {
|
||||
value: request.pin,
|
||||
length: request.pin.length,
|
||||
}
|
||||
: undefined,
|
||||
claims: stringClaims,
|
||||
};
|
||||
|
||||
// Add display properties if configured
|
||||
if (this.config.logoUri || this.config.backgroundColor || this.config.textColor) {
|
||||
requestBody.display = {
|
||||
...(this.config.logoUri && { logo: { uri: this.config.logoUri } }),
|
||||
...(this.config.backgroundColor && { backgroundColor: this.config.backgroundColor }),
|
||||
...(this.config.textColor && { textColor: this.config.textColor }),
|
||||
};
|
||||
}
|
||||
|
||||
const response = await fetch(issueUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Failed to issue credential: ${response.status} ${errorText}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
requestId: string;
|
||||
url: string;
|
||||
expiry: number;
|
||||
qrCode?: string;
|
||||
};
|
||||
|
||||
return {
|
||||
requestId: data.requestId,
|
||||
url: data.url,
|
||||
expiry: data.expiry,
|
||||
qrCode: data.qrCode,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check issuance status
|
||||
*/
|
||||
async getIssuanceStatus(requestId: string): Promise<VerifiableCredentialStatus> {
|
||||
const token = await this.getAccessToken();
|
||||
const statusUrl = `${this.baseUrl}/verifiableCredentials/issuanceRequests/${requestId}`;
|
||||
|
||||
const response = await fetch(statusUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Failed to get issuance status: ${response.status} ${errorText}`);
|
||||
}
|
||||
|
||||
return (await response.json()) as VerifiableCredentialStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate credential structure
|
||||
*/
|
||||
private validateCredential(credential: VerifiedCredential): void {
|
||||
if (!credential.id) {
|
||||
throw new Error('Credential ID is required');
|
||||
}
|
||||
|
||||
if (!credential.type || !Array.isArray(credential.type) || credential.type.length === 0) {
|
||||
throw new Error('Credential type is required and must be an array');
|
||||
}
|
||||
|
||||
if (!credential.issuer) {
|
||||
throw new Error('Credential issuer is required');
|
||||
}
|
||||
|
||||
if (!credential.issuanceDate) {
|
||||
throw new Error('Credential issuance date is required');
|
||||
}
|
||||
|
||||
if (!credential.credentialSubject || typeof credential.credentialSubject !== 'object') {
|
||||
throw new Error('Credential subject is required');
|
||||
}
|
||||
|
||||
if (!credential.proof) {
|
||||
throw new Error('Credential proof is required');
|
||||
}
|
||||
|
||||
// Validate proof structure
|
||||
if (!credential.proof.type || !credential.proof.jws) {
|
||||
throw new Error('Credential proof must include type and jws');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a verifiable credential
|
||||
*/
|
||||
async verifyCredential(credential: VerifiedCredential): Promise<boolean> {
|
||||
// Validate credential structure
|
||||
this.validateCredential(credential);
|
||||
|
||||
const token = await this.getAccessToken();
|
||||
const verifyUrl = `${this.baseUrl}/verifiableCredentials/verify`;
|
||||
|
||||
try {
|
||||
const response = await fetch(verifyUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
verifiableCredential: credential,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Verification failed: ${response.status} ${errorText}`);
|
||||
}
|
||||
|
||||
const result = (await response.json()) as { verified: boolean };
|
||||
return result.verified ?? false;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('Verification failed')) {
|
||||
throw error;
|
||||
}
|
||||
throw new Error(`Failed to verify credential: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a presentation request for credential verification
|
||||
*/
|
||||
async createPresentationRequest(
|
||||
manifestId: string,
|
||||
callbackUrl?: string
|
||||
): Promise<VerifiableCredentialResponse> {
|
||||
const token = await this.getAccessToken();
|
||||
const requestUrl = `${this.baseUrl}/verifiableCredentials/createPresentationRequest`;
|
||||
|
||||
const requestBody = {
|
||||
includeQRCode: true,
|
||||
callback: callbackUrl
|
||||
? {
|
||||
url: callbackUrl,
|
||||
state: crypto.randomUUID(),
|
||||
}
|
||||
: undefined,
|
||||
authority: `did:web:${this.config.tenantId}.verifiedid.msidentity.com`,
|
||||
registration: {
|
||||
clientName: 'The Order',
|
||||
},
|
||||
requestedCredentials: [
|
||||
{
|
||||
type: manifestId,
|
||||
manifestId,
|
||||
acceptedIssuers: [`did:web:${this.config.tenantId}.verifiedid.msidentity.com`],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const response = await fetch(requestUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Failed to create presentation request: ${response.status} ${errorText}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
requestId: string;
|
||||
url: string;
|
||||
expiry: number;
|
||||
qrCode?: string;
|
||||
};
|
||||
|
||||
return {
|
||||
requestId: data.requestId,
|
||||
url: data.url,
|
||||
expiry: data.expiry,
|
||||
qrCode: data.qrCode,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* File handling utilities for Entra VerifiedID and other integrations
|
||||
* File handling utilities for credential and document integrations
|
||||
* Provides base64 encoding/decoding, validation, and content type detection
|
||||
*/
|
||||
|
||||
@@ -375,4 +375,3 @@ export function calculateFileHash(data: Buffer | string, algorithm: 'sha256' | '
|
||||
|
||||
return createHash(algorithm).update(buffer).digest('hex');
|
||||
}
|
||||
|
||||
|
||||
@@ -5,10 +5,4 @@
|
||||
export * from './oidc';
|
||||
export * from './did';
|
||||
export * from './eidas';
|
||||
export * from './entra-verifiedid';
|
||||
export * from './entra-verifiedid-enhanced';
|
||||
export * from './entra-credential-images';
|
||||
export * from './azure-logic-apps';
|
||||
export * from './eidas-entra-bridge';
|
||||
export * from './file-utils';
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from '@aws-sdk/client-kms';
|
||||
|
||||
export interface KMSConfig {
|
||||
provider: 'aws' | 'gcp' | 'azure' | 'hsm';
|
||||
provider: 'aws' | 'gcp' | 'hsm';
|
||||
keyId: string;
|
||||
region?: string;
|
||||
}
|
||||
@@ -83,4 +83,3 @@ export class KMSClient {
|
||||
return response.SignatureValid ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
/**
|
||||
* Microsoft Entra VerifiedID metrics
|
||||
* Tracks Entra VerifiedID API usage, success rates, and performance
|
||||
*/
|
||||
|
||||
import { Counter, Histogram, Gauge, register } from 'prom-client';
|
||||
|
||||
// Entra API request metrics
|
||||
export const entraApiRequests = new Counter({
|
||||
name: 'entra_api_requests_total',
|
||||
help: 'Total number of Entra VerifiedID API requests',
|
||||
labelNames: ['operation', 'status'],
|
||||
registers: [register],
|
||||
});
|
||||
|
||||
export const entraApiRequestDuration = new Histogram({
|
||||
name: 'entra_api_request_duration_seconds',
|
||||
help: 'Duration of Entra VerifiedID API requests',
|
||||
labelNames: ['operation'],
|
||||
buckets: [0.1, 0.5, 1, 2, 5, 10, 30],
|
||||
registers: [register],
|
||||
});
|
||||
|
||||
export const entraApiErrors = new Counter({
|
||||
name: 'entra_api_errors_total',
|
||||
help: 'Total number of Entra VerifiedID API errors',
|
||||
labelNames: ['operation', 'error_type', 'status_code'],
|
||||
registers: [register],
|
||||
});
|
||||
|
||||
// Credential issuance metrics
|
||||
export const entraCredentialsIssued = new Counter({
|
||||
name: 'entra_credentials_issued_total',
|
||||
help: 'Total number of credentials issued via Entra VerifiedID',
|
||||
labelNames: ['manifest_name', 'status'],
|
||||
registers: [register],
|
||||
});
|
||||
|
||||
export const entraIssuanceDuration = new Histogram({
|
||||
name: 'entra_issuance_duration_seconds',
|
||||
help: 'Time to issue a credential via Entra VerifiedID',
|
||||
labelNames: ['manifest_name'],
|
||||
buckets: [0.5, 1, 2, 5, 10, 30, 60],
|
||||
registers: [register],
|
||||
});
|
||||
|
||||
export const entraIssuanceRetries = new Counter({
|
||||
name: 'entra_issuance_retries_total',
|
||||
help: 'Total number of retries for Entra credential issuance',
|
||||
labelNames: ['manifest_name', 'attempt'],
|
||||
registers: [register],
|
||||
});
|
||||
|
||||
// Credential verification metrics
|
||||
export const entraCredentialsVerified = new Counter({
|
||||
name: 'entra_credentials_verified_total',
|
||||
help: 'Total number of credentials verified via Entra VerifiedID',
|
||||
labelNames: ['result'],
|
||||
registers: [register],
|
||||
});
|
||||
|
||||
export const entraVerificationDuration = new Histogram({
|
||||
name: 'entra_verification_duration_seconds',
|
||||
help: 'Time to verify a credential via Entra VerifiedID',
|
||||
buckets: [0.1, 0.5, 1, 2, 5],
|
||||
registers: [register],
|
||||
});
|
||||
|
||||
// Status check metrics
|
||||
export const entraStatusChecks = new Counter({
|
||||
name: 'entra_status_checks_total',
|
||||
help: 'Total number of Entra issuance status checks',
|
||||
labelNames: ['state'],
|
||||
registers: [register],
|
||||
});
|
||||
|
||||
export const entraStatusCheckDuration = new Histogram({
|
||||
name: 'entra_status_check_duration_seconds',
|
||||
help: 'Time to check Entra issuance status',
|
||||
buckets: [0.1, 0.5, 1, 2, 5],
|
||||
registers: [register],
|
||||
});
|
||||
|
||||
// Token management metrics
|
||||
export const entraTokenRefreshes = new Counter({
|
||||
name: 'entra_token_refreshes_total',
|
||||
help: 'Total number of Entra access token refreshes',
|
||||
registers: [register],
|
||||
});
|
||||
|
||||
export const entraTokenCacheHits = new Counter({
|
||||
name: 'entra_token_cache_hits_total',
|
||||
help: 'Total number of Entra token cache hits',
|
||||
registers: [register],
|
||||
});
|
||||
|
||||
export const entraTokenCacheMisses = new Counter({
|
||||
name: 'entra_token_cache_misses_total',
|
||||
help: 'Total number of Entra token cache misses',
|
||||
registers: [register],
|
||||
});
|
||||
|
||||
// Webhook/callback metrics
|
||||
export const entraWebhooksReceived = new Counter({
|
||||
name: 'entra_webhooks_received_total',
|
||||
help: 'Total number of Entra webhooks received',
|
||||
labelNames: ['event_type', 'status'],
|
||||
registers: [register],
|
||||
});
|
||||
|
||||
export const entraWebhookProcessingDuration = new Histogram({
|
||||
name: 'entra_webhook_processing_duration_seconds',
|
||||
help: 'Time to process an Entra webhook',
|
||||
labelNames: ['event_type'],
|
||||
buckets: [0.1, 0.5, 1, 2, 5],
|
||||
registers: [register],
|
||||
});
|
||||
|
||||
export const entraWebhookErrors = new Counter({
|
||||
name: 'entra_webhook_errors_total',
|
||||
help: 'Total number of Entra webhook processing errors',
|
||||
labelNames: ['event_type', 'error_type'],
|
||||
registers: [register],
|
||||
});
|
||||
|
||||
// Active requests gauge
|
||||
export const entraActiveRequests = new Gauge({
|
||||
name: 'entra_active_requests',
|
||||
help: 'Number of active Entra API requests',
|
||||
labelNames: ['operation'],
|
||||
registers: [register],
|
||||
});
|
||||
|
||||
// Success rate calculation helpers
|
||||
// Note: This is a helper function - actual success rate should be calculated
|
||||
// from Prometheus queries like:
|
||||
// rate(entra_credentials_issued_total{status="success"}[5m]) / rate(entra_credentials_issued_total[5m])
|
||||
export function getEntraIssuanceSuccessRate(): number {
|
||||
// This function is a placeholder - actual calculation should be done via Prometheus queries
|
||||
// as prom-client doesn't expose internal metrics directly
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all Entra metrics in Prometheus format
|
||||
*/
|
||||
export async function getEntraMetrics(): Promise<string> {
|
||||
return register.metrics();
|
||||
}
|
||||
|
||||
@@ -5,11 +5,9 @@
|
||||
export * from './otel';
|
||||
export * from './metrics';
|
||||
export * from './business-metrics';
|
||||
export * from './entra-metrics';
|
||||
|
||||
// Re-export business metrics with explicit names to avoid conflicts
|
||||
export {
|
||||
documentsProcessed,
|
||||
paymentsProcessed,
|
||||
} from './business-metrics';
|
||||
|
||||
|
||||
@@ -44,31 +44,10 @@ const envSchema = z.object({
|
||||
EIDAS_PROVIDER_URL: z.string().url().optional(),
|
||||
EIDAS_API_KEY: z.string().optional(),
|
||||
|
||||
// Microsoft Entra VerifiedID
|
||||
ENTRA_TENANT_ID: z.string().optional(),
|
||||
ENTRA_CLIENT_ID: z.string().optional(),
|
||||
ENTRA_CLIENT_SECRET: z.string().optional(),
|
||||
ENTRA_CREDENTIAL_MANIFEST_ID: z.string().optional(),
|
||||
ENTRA_MANIFESTS: z.string().optional(), // JSON object mapping manifest names to IDs
|
||||
// Entra Rate Limiting
|
||||
ENTRA_RATE_LIMIT_ISSUANCE: z.string().optional(),
|
||||
ENTRA_RATE_LIMIT_VERIFICATION: z.string().optional(),
|
||||
ENTRA_RATE_LIMIT_STATUS_CHECK: z.string().optional(),
|
||||
ENTRA_RATE_LIMIT_GLOBAL: z.string().optional(),
|
||||
// Credential Display/Images
|
||||
ENTRA_CREDENTIAL_LOGO_URI: z.string().url().optional(),
|
||||
ENTRA_CREDENTIAL_BG_COLOR: z.string().optional(),
|
||||
ENTRA_CREDENTIAL_TEXT_COLOR: z.string().optional(),
|
||||
|
||||
// Credential Rate Limiting
|
||||
CREDENTIAL_RATE_LIMIT_PER_USER: z.string().optional(),
|
||||
CREDENTIAL_RATE_LIMIT_PER_IP: z.string().optional(),
|
||||
|
||||
// Azure Logic Apps
|
||||
AZURE_LOGIC_APPS_WORKFLOW_URL: z.string().url().optional(),
|
||||
AZURE_LOGIC_APPS_ACCESS_KEY: z.string().optional(),
|
||||
AZURE_LOGIC_APPS_MANAGED_IDENTITY_CLIENT_ID: z.string().optional(),
|
||||
|
||||
// CORS
|
||||
CORS_ORIGIN: z.string().optional(),
|
||||
|
||||
@@ -135,12 +114,7 @@ const envSchema = z.object({
|
||||
DSB_SCHEMA_REGISTRY_URL: z.string().url().optional(),
|
||||
|
||||
// Secrets Management
|
||||
SECRETS_PROVIDER: z.enum(['aws', 'azure', 'env']).optional(),
|
||||
AZURE_KEY_VAULT_URL: z.string().url().optional(),
|
||||
AZURE_TENANT_ID: z.string().optional(),
|
||||
AZURE_CLIENT_ID: z.string().optional(),
|
||||
AZURE_CLIENT_SECRET: z.string().optional(),
|
||||
AZURE_MANAGED_IDENTITY_CLIENT_ID: z.string().optional(),
|
||||
SECRETS_PROVIDER: z.enum(['aws', 'gcp', 'env']).optional(),
|
||||
SECRETS_CACHE_TTL: z.string().transform(Number).pipe(z.number().int().positive()).optional(),
|
||||
});
|
||||
|
||||
@@ -175,4 +149,3 @@ export function getEnv(): Env {
|
||||
* Validate environment variables on module load
|
||||
*/
|
||||
getEnv();
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ export * from './auth';
|
||||
export * from './rate-limit-credential';
|
||||
export * from './rate-limiting';
|
||||
export * from './graceful-shutdown';
|
||||
export * from './rate-limit-entra';
|
||||
export * from './authorization';
|
||||
export * from './compliance';
|
||||
export * from './retry';
|
||||
@@ -22,4 +21,3 @@ export * from './timeout';
|
||||
|
||||
// Re-export types
|
||||
export type { AuthUser } from './auth';
|
||||
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
/**
|
||||
* Entra VerifiedID API rate limiting
|
||||
* Specific rate limits for Entra VerifiedID endpoints to prevent API quota exhaustion
|
||||
*/
|
||||
|
||||
import { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import fastifyRateLimit from '@fastify/rate-limit';
|
||||
import { getEnv } from './env';
|
||||
|
||||
export interface EntraRateLimitConfig {
|
||||
issuance?: {
|
||||
max: number;
|
||||
timeWindow: string | number;
|
||||
};
|
||||
verification?: {
|
||||
max: number;
|
||||
timeWindow: string | number;
|
||||
};
|
||||
statusCheck?: {
|
||||
max: number;
|
||||
timeWindow: string | number;
|
||||
};
|
||||
global?: {
|
||||
max: number;
|
||||
timeWindow: string | number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Register Entra VerifiedID-specific rate limiting
|
||||
*/
|
||||
export async function registerEntraRateLimit(
|
||||
server: FastifyInstance,
|
||||
config?: EntraRateLimitConfig
|
||||
): Promise<void> {
|
||||
const env = getEnv();
|
||||
|
||||
// Default configuration - conservative limits to avoid API quota issues
|
||||
const defaultConfig: Required<EntraRateLimitConfig> = {
|
||||
issuance: {
|
||||
max: parseInt(env.ENTRA_RATE_LIMIT_ISSUANCE || '10', 10),
|
||||
timeWindow: '1 minute',
|
||||
},
|
||||
verification: {
|
||||
max: parseInt(env.ENTRA_RATE_LIMIT_VERIFICATION || '20', 10),
|
||||
timeWindow: '1 minute',
|
||||
},
|
||||
statusCheck: {
|
||||
max: parseInt(env.ENTRA_RATE_LIMIT_STATUS_CHECK || '30', 10),
|
||||
timeWindow: '1 minute',
|
||||
},
|
||||
global: {
|
||||
max: parseInt(env.ENTRA_RATE_LIMIT_GLOBAL || '50', 10),
|
||||
timeWindow: '1 minute',
|
||||
},
|
||||
};
|
||||
|
||||
const finalConfig = { ...defaultConfig, ...config };
|
||||
|
||||
// Global Entra API rate limit
|
||||
await server.register(fastifyRateLimit, {
|
||||
max: finalConfig.global.max,
|
||||
timeWindow: finalConfig.global.timeWindow,
|
||||
keyGenerator: (request: FastifyRequest) => {
|
||||
// Rate limit by IP for Entra endpoints
|
||||
return `entra:global:${request.ip}`;
|
||||
},
|
||||
errorResponseBuilder: (_request, context) => {
|
||||
return {
|
||||
error: {
|
||||
code: 'ENTRA_RATE_LIMIT_EXCEEDED',
|
||||
message: `Entra API rate limit exceeded, retry in ${Math.ceil(context.ttl / 1000)} seconds`,
|
||||
},
|
||||
};
|
||||
},
|
||||
skipOnError: false,
|
||||
});
|
||||
|
||||
// Issuance-specific rate limit
|
||||
await server.register(fastifyRateLimit, {
|
||||
max: finalConfig.issuance.max,
|
||||
timeWindow: finalConfig.issuance.timeWindow,
|
||||
keyGenerator: (request: FastifyRequest) => {
|
||||
const userId = (request as any).user?.id || 'anonymous';
|
||||
return `entra:issuance:${userId}:${request.ip}`;
|
||||
},
|
||||
errorResponseBuilder: (_request, context) => {
|
||||
return {
|
||||
error: {
|
||||
code: 'ENTRA_ISSUANCE_RATE_LIMIT_EXCEEDED',
|
||||
message: `Entra issuance rate limit exceeded, retry in ${Math.ceil(context.ttl / 1000)} seconds`,
|
||||
},
|
||||
};
|
||||
},
|
||||
skipOnError: false,
|
||||
});
|
||||
|
||||
// Verification-specific rate limit
|
||||
await server.register(fastifyRateLimit, {
|
||||
max: finalConfig.verification.max,
|
||||
timeWindow: finalConfig.verification.timeWindow,
|
||||
keyGenerator: (request: FastifyRequest) => {
|
||||
return `entra:verification:${request.ip}`;
|
||||
},
|
||||
errorResponseBuilder: (_request, context) => {
|
||||
return {
|
||||
error: {
|
||||
code: 'ENTRA_VERIFICATION_RATE_LIMIT_EXCEEDED',
|
||||
message: `Entra verification rate limit exceeded, retry in ${Math.ceil(context.ttl / 1000)} seconds`,
|
||||
},
|
||||
};
|
||||
},
|
||||
skipOnError: false,
|
||||
});
|
||||
|
||||
// Status check-specific rate limit
|
||||
await server.register(fastifyRateLimit, {
|
||||
max: finalConfig.statusCheck.max,
|
||||
timeWindow: finalConfig.statusCheck.timeWindow,
|
||||
keyGenerator: (request: FastifyRequest) => {
|
||||
const requestId = (request.params as any)?.requestId || (request.query as any)?.requestId || 'unknown';
|
||||
return `entra:status:${requestId}`;
|
||||
},
|
||||
errorResponseBuilder: (_request, context) => {
|
||||
return {
|
||||
error: {
|
||||
code: 'ENTRA_STATUS_CHECK_RATE_LIMIT_EXCEEDED',
|
||||
message: `Entra status check rate limit exceeded, retry in ${Math.ceil(context.ttl / 1000)} seconds`,
|
||||
},
|
||||
};
|
||||
},
|
||||
skipOnError: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Entra rate limit plugin
|
||||
*/
|
||||
export function createEntraRateLimitPlugin(config?: EntraRateLimitConfig) {
|
||||
return async function (server: FastifyInstance) {
|
||||
await registerEntraRateLimit(server, config);
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user