docs: archive entra materials and simplify deployment docs

This commit is contained in:
defiQUG
2026-04-18 12:05:55 -07:00
parent 0f600e6a31
commit bbb6ce6a6c
256 changed files with 4188 additions and 3881 deletions
+1 -2
View File
@@ -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
-1
View File
@@ -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",
-150
View File
@@ -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(),
},
});
}
}
-254
View File
@@ -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();
});
});
-371
View File
@@ -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);
});
});
});
-406
View File
@@ -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 -2
View File
@@ -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');
}
-6
View File
@@ -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';
+1 -2
View File
@@ -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;
}
}
-150
View File
@@ -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();
}
-2
View File
@@ -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';
+1 -28
View File
@@ -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();
-2
View File
@@ -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';
-144
View File
@@ -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);
};
}