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:
defiQUG
2025-11-12 22:03:42 -08:00
parent 8649ad4124
commit 92cc41d26d
258 changed files with 16021 additions and 1260 deletions

View File

@@ -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)

View File

@@ -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
);

View File

@@ -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);
}
}

View 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
};
}

View 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 };
}
}

View 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();
});
});

View 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);
});
});
});

View File

@@ -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: {

View File

@@ -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
}

View File

@@ -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';