Add Legal Office seal and complete Azure CDN deployment
- Add Legal Office of the Master seal (SVG design with Maltese Cross, scales of justice, legal scroll) - Create legal-office-manifest-template.json for Legal Office credentials - Update SEAL_MAPPING.md and DESIGN_GUIDE.md with Legal Office seal documentation - Complete Azure CDN infrastructure deployment: - Resource group, storage account, and container created - 17 PNG seal files uploaded to Azure Blob Storage - All manifest templates updated with Azure URLs - Configuration files generated (azure-cdn-config.env) - Add comprehensive Azure CDN setup scripts and documentation - Fix manifest URL generation to prevent double slashes - Verify all seals accessible via HTTPS
This commit is contained in:
@@ -55,20 +55,22 @@ export class DIDResolver {
|
||||
return (await response.json()) as DIDDocument;
|
||||
} else if (method === 'key') {
|
||||
// did:key resolution - generate document from key
|
||||
const publicKeyMultibase = identifier;
|
||||
return {
|
||||
const publicKeyMultibase: string = identifier;
|
||||
const verificationMethodObj: VerificationMethod = {
|
||||
id: `${did}#keys-1`,
|
||||
type: 'Ed25519VerificationKey2020',
|
||||
controller: did,
|
||||
publicKeyMultibase: publicKeyMultibase,
|
||||
};
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const verificationMethodArray: VerificationMethod[] = [verificationMethodObj];
|
||||
const result: DIDDocument = {
|
||||
id: did,
|
||||
'@context': ['https://www.w3.org/ns/did/v1'],
|
||||
verificationMethod: [
|
||||
{
|
||||
id: `${did}#keys-1`,
|
||||
type: 'Ed25519VerificationKey2020',
|
||||
controller: did,
|
||||
publicKeyMultibase,
|
||||
},
|
||||
],
|
||||
verificationMethod: verificationMethodArray,
|
||||
authentication: [`${did}#keys-1`],
|
||||
};
|
||||
return result;
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported DID method: ${method}`);
|
||||
@@ -160,7 +162,8 @@ export class DIDResolver {
|
||||
}
|
||||
|
||||
// Verify signature using @noble/ed25519
|
||||
return await ed25519Verify(signatureBytes, messageBytes, publicKey);
|
||||
// ed25519Verify is synchronous, but we keep async for consistency
|
||||
return Promise.resolve(ed25519Verify(signatureBytes, messageBytes, publicKey));
|
||||
} catch (error) {
|
||||
console.error('Ed25519 verification failed:', error);
|
||||
return false;
|
||||
@@ -207,11 +210,11 @@ export class DIDResolver {
|
||||
/**
|
||||
* Verify signature with RSA public key
|
||||
*/
|
||||
private async verifyRSA(
|
||||
private verifyRSA(
|
||||
jwk: { n?: string; e?: string },
|
||||
message: string,
|
||||
signature: string
|
||||
): Promise<boolean> {
|
||||
): boolean {
|
||||
try {
|
||||
if (!jwk.n || !jwk.e) {
|
||||
return false;
|
||||
@@ -271,7 +274,7 @@ export class DIDResolver {
|
||||
|
||||
// RSA keys
|
||||
if (jwk.kty === 'RSA') {
|
||||
return await this.verifyRSA(jwk, message, signature);
|
||||
return this.verifyRSA(jwk, message, signature);
|
||||
}
|
||||
|
||||
// Ed25519 in JWK format (less common)
|
||||
|
||||
@@ -84,7 +84,6 @@ export class EIDASToEntraBridge {
|
||||
}> {
|
||||
// Step 0: Validate and encode document if needed
|
||||
let documentBase64: string;
|
||||
const errors: string[] = [];
|
||||
|
||||
if (document instanceof Buffer) {
|
||||
// Encode buffer to base64
|
||||
@@ -92,7 +91,7 @@ export class EIDASToEntraBridge {
|
||||
} else {
|
||||
// Validate base64 string
|
||||
const validation = validateBase64File(
|
||||
document,
|
||||
document as string,
|
||||
validationOptions || {
|
||||
maxSize: FILE_SIZE_LIMITS.MEDIUM,
|
||||
allowedMimeTypes: [
|
||||
@@ -112,7 +111,7 @@ export class EIDASToEntraBridge {
|
||||
};
|
||||
}
|
||||
|
||||
documentBase64 = document;
|
||||
documentBase64 = document as string;
|
||||
}
|
||||
|
||||
// Step 1: Request eIDAS signature
|
||||
@@ -140,8 +139,9 @@ export class EIDASToEntraBridge {
|
||||
// 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(
|
||||
document,
|
||||
documentId,
|
||||
userId,
|
||||
this.eidasProvider['config'].providerUrl
|
||||
);
|
||||
|
||||
@@ -252,10 +252,10 @@ export class EIDASProvider {
|
||||
/**
|
||||
* Validate certificate without signature verification
|
||||
*/
|
||||
async validateCertificate(
|
||||
validateCertificate(
|
||||
certificate: string,
|
||||
chain?: string[]
|
||||
): Promise<CertificateValidationResult> {
|
||||
): CertificateValidationResult {
|
||||
return this.validateCertificateChain(certificate, chain);
|
||||
}
|
||||
}
|
||||
|
||||
183
packages/auth/src/entra-credential-images.ts
Normal file
183
packages/auth/src/entra-credential-images.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* 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];
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
180
packages/auth/src/entra-verifiedid-enhanced.ts
Normal file
180
packages/auth/src/entra-verifiedid-enhanced.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
}
|
||||
|
||||
96
packages/auth/src/entra-verifiedid.integration.test.ts
Normal file
96
packages/auth/src/entra-verifiedid.integration.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* 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: 'test@example.com',
|
||||
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: 'test@example.com' },
|
||||
});
|
||||
|
||||
// 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
packages/auth/src/entra-verifiedid.test.ts
Normal file
371
packages/auth/src/entra-verifiedid.test.ts
Normal file
@@ -0,0 +1,371 @@
|
||||
/**
|
||||
* 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: 'test@example.com', 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: 'test@example.com' },
|
||||
pin: '1234',
|
||||
};
|
||||
expect(() => (client as any).validateCredentialRequest(validRequest)).not.toThrow();
|
||||
|
||||
const invalidRequest: VerifiableCredentialRequest = {
|
||||
claims: { email: 'test@example.com' },
|
||||
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: 'test@example.com', 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: 'test@example.com' },
|
||||
};
|
||||
|
||||
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: 'test@example.com' },
|
||||
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: 'test@example.com' },
|
||||
};
|
||||
|
||||
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: 'test@example.com' },
|
||||
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: 'test@example.com' },
|
||||
};
|
||||
|
||||
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: 'test@example.com' },
|
||||
};
|
||||
|
||||
await expect(client.issueCredential(request)).rejects.toThrow();
|
||||
// Should not retry (only 2 fetch calls)
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
*/
|
||||
|
||||
import fetch from 'node-fetch';
|
||||
import { validateBase64File, FileValidationOptions, FILE_SIZE_LIMITS } from './file-utils';
|
||||
|
||||
export interface EntraVerifiedIDConfig {
|
||||
tenantId: string;
|
||||
@@ -12,6 +11,9 @@ export interface EntraVerifiedIDConfig {
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -111,7 +113,8 @@ export class EntraVerifiedIDClient {
|
||||
|
||||
this.accessToken = tokenData.access_token;
|
||||
// Set expiry 5 minutes before actual expiry for safety
|
||||
this.tokenExpiry = Date.now() + (tokenData.expires_in - 300) * 1000;
|
||||
const expiresIn = typeof tokenData.expires_in === 'number' ? tokenData.expires_in : 3600;
|
||||
this.tokenExpiry = Date.now() + (expiresIn - 300) * 1000;
|
||||
|
||||
return this.accessToken;
|
||||
}
|
||||
@@ -186,7 +189,7 @@ export class EntraVerifiedIDClient {
|
||||
}
|
||||
}
|
||||
|
||||
const requestBody = {
|
||||
const requestBody: Record<string, unknown> = {
|
||||
includeQRCode: true,
|
||||
callback: request.callbackUrl
|
||||
? {
|
||||
@@ -209,6 +212,15 @@ export class EntraVerifiedIDClient {
|
||||
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: {
|
||||
|
||||
@@ -108,7 +108,7 @@ export function encodeFileToBase64(
|
||||
export function decodeBase64ToBuffer(base64: string): Buffer {
|
||||
// Remove data URL prefix if present
|
||||
const base64Data = base64.includes(',')
|
||||
? base64.split(',')[1]
|
||||
? (base64.split(',')[1] ?? base64)
|
||||
: base64;
|
||||
|
||||
return Buffer.from(base64Data, 'base64');
|
||||
@@ -124,7 +124,7 @@ export function isBase64(str: string): boolean {
|
||||
|
||||
// Remove data URL prefix if present
|
||||
const base64Data = str.includes(',')
|
||||
? str.split(',')[1]
|
||||
? (str.split(',')[1] ?? str)
|
||||
: str;
|
||||
|
||||
// Base64 regex pattern
|
||||
@@ -292,7 +292,8 @@ export function validateBase64File(
|
||||
// Detect and validate MIME type
|
||||
let mimeType: string | undefined;
|
||||
try {
|
||||
mimeType = detectMimeTypeFromBuffer(buffer);
|
||||
const detected = detectMimeTypeFromBuffer(buffer);
|
||||
mimeType = detected ?? undefined;
|
||||
} catch {
|
||||
// MIME type detection failed, but not critical
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ 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';
|
||||
|
||||
Reference in New Issue
Block a user