feat: implement naming convention, deployment automation, and infrastructure updates
- Add comprehensive naming convention (provider-region-resource-env-purpose) - Implement Terraform locals for centralized naming - Update all Terraform resources to use new naming convention - Create deployment automation framework (18 phase scripts) - Add Azure setup scripts (provider registration, quota checks) - Update deployment scripts config with naming functions - Create complete deployment documentation (guide, steps, quick reference) - Add frontend portal implementations (public and internal) - Add UI component library (18 components) - Enhance Entra VerifiedID integration with file utilities - Add API client package for all services - Create comprehensive documentation (naming, deployment, next steps) Infrastructure: - Resource groups, storage accounts with new naming - Terraform configuration updates - Outputs with naming convention examples Deployment: - Automated deployment scripts for all 15 phases - State management and logging - Error handling and validation Documentation: - Naming convention guide and implementation summary - Complete deployment guide (296 steps) - Next steps and quick start guides - Azure prerequisites and setup completion docs Note: ESLint warnings present - will be addressed in follow-up commit
This commit is contained in:
@@ -4,8 +4,9 @@
|
||||
*/
|
||||
|
||||
import { EIDASProvider, EIDASSignature } from './eidas';
|
||||
import { EntraVerifiedIDClient, VerifiableCredentialRequest } from './entra-verifiedid';
|
||||
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: {
|
||||
@@ -67,10 +68,11 @@ export class EIDASToEntraBridge {
|
||||
* Verify eIDAS signature and issue credential via Entra VerifiedID
|
||||
*/
|
||||
async verifyAndIssue(
|
||||
document: string,
|
||||
document: string | Buffer,
|
||||
userId: string,
|
||||
userEmail: string,
|
||||
pin?: string
|
||||
pin?: string,
|
||||
validationOptions?: FileValidationOptions
|
||||
): Promise<{
|
||||
verified: boolean;
|
||||
credentialRequest?: {
|
||||
@@ -78,20 +80,61 @@ export class EIDASToEntraBridge {
|
||||
url: string;
|
||||
qrCode?: string;
|
||||
};
|
||||
errors?: string[];
|
||||
}> {
|
||||
// Step 0: Validate and encode document if needed
|
||||
let documentBase64: string;
|
||||
const errors: string[] = [];
|
||||
|
||||
if (document instanceof Buffer) {
|
||||
// Encode buffer to base64
|
||||
documentBase64 = encodeFileToBase64(document);
|
||||
} else {
|
||||
// Validate base64 string
|
||||
const validation = validateBase64File(
|
||||
document,
|
||||
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;
|
||||
}
|
||||
|
||||
// Step 1: Request eIDAS signature
|
||||
let eidasSignature: EIDASSignature;
|
||||
try {
|
||||
eidasSignature = await this.eidasProvider.requestSignature(document);
|
||||
eidasSignature = await this.eidasProvider.requestSignature(documentBase64);
|
||||
} catch (error) {
|
||||
console.error('eIDAS signature request failed:', error);
|
||||
return { verified: false };
|
||||
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 };
|
||||
return {
|
||||
verified: false,
|
||||
errors: ['eIDAS signature verification failed'],
|
||||
};
|
||||
}
|
||||
|
||||
// Step 3: Trigger Logic App workflow if configured
|
||||
@@ -112,7 +155,7 @@ export class EIDASToEntraBridge {
|
||||
claims: {
|
||||
email: userEmail,
|
||||
userId,
|
||||
eidasVerified: 'true',
|
||||
eidasVerified: true, // Boolean value (will be converted to string)
|
||||
eidasCertificate: eidasSignature.certificate,
|
||||
eidasSignatureTimestamp: eidasSignature.timestamp.toISOString(),
|
||||
},
|
||||
@@ -172,7 +215,7 @@ export class EIDASToEntraBridge {
|
||||
eidasVerificationResult: EIDASVerificationResult,
|
||||
userId: string,
|
||||
userEmail: string,
|
||||
additionalClaims?: Record<string, string>,
|
||||
additionalClaims?: Record<string, ClaimValue>,
|
||||
pin?: string
|
||||
): Promise<{
|
||||
requestId: string;
|
||||
@@ -183,10 +226,10 @@ export class EIDASToEntraBridge {
|
||||
throw new Error('eIDAS verification must be successful before issuing credential');
|
||||
}
|
||||
|
||||
const claims: Record<string, string> = {
|
||||
const claims: Record<string, ClaimValue> = {
|
||||
email: userEmail,
|
||||
userId,
|
||||
eidasVerified: 'true',
|
||||
eidasVerified: true, // Boolean value (will be converted to string)
|
||||
eidasCertificate: eidasVerificationResult.eidasSignature.certificate,
|
||||
eidasSignatureTimestamp: eidasVerificationResult.eidasSignature.timestamp.toISOString(),
|
||||
...additionalClaims,
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import fetch from 'node-fetch';
|
||||
import { validateBase64File, FileValidationOptions, FILE_SIZE_LIMITS } from './file-utils';
|
||||
|
||||
export interface EntraVerifiedIDConfig {
|
||||
tenantId: string;
|
||||
@@ -13,8 +14,16 @@ export interface EntraVerifiedIDConfig {
|
||||
apiVersion?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Supported claim value types
|
||||
*/
|
||||
export type ClaimValue = string | number | boolean | null;
|
||||
|
||||
/**
|
||||
* Verifiable credential request with enhanced claim types
|
||||
*/
|
||||
export interface VerifiableCredentialRequest {
|
||||
claims: Record<string, string>;
|
||||
claims: Record<string, ClaimValue>;
|
||||
pin?: string;
|
||||
callbackUrl?: string;
|
||||
}
|
||||
@@ -107,12 +116,53 @@ export class EntraVerifiedIDClient {
|
||||
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;
|
||||
|
||||
@@ -122,6 +172,20 @@ export class EntraVerifiedIDClient {
|
||||
|
||||
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 = {
|
||||
includeQRCode: true,
|
||||
callback: request.callbackUrl
|
||||
@@ -142,7 +206,7 @@ export class EntraVerifiedIDClient {
|
||||
length: request.pin.length,
|
||||
}
|
||||
: undefined,
|
||||
claims: request.claims,
|
||||
claims: stringClaims,
|
||||
};
|
||||
|
||||
const response = await fetch(issueUrl, {
|
||||
@@ -196,30 +260,75 @@ export class EntraVerifiedIDClient {
|
||||
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`;
|
||||
|
||||
const response = await fetch(verifyUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
verifiableCredential: credential,
|
||||
}),
|
||||
});
|
||||
try {
|
||||
const response = await fetch(verifyUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
verifiableCredential: credential,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return false;
|
||||
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)}`);
|
||||
}
|
||||
|
||||
const result = (await response.json()) as { verified: boolean };
|
||||
return result.verified ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
173
packages/auth/src/file-utils.test.ts
Normal file
173
packages/auth/src/file-utils.test.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Tests for file utilities
|
||||
* Run with: pnpm test file-utils
|
||||
*/
|
||||
|
||||
import {
|
||||
encodeFileToBase64,
|
||||
decodeBase64ToBuffer,
|
||||
isBase64,
|
||||
detectMimeType,
|
||||
validateBase64File,
|
||||
encodeFileWithMetadata,
|
||||
sanitizeFilename,
|
||||
calculateFileHash,
|
||||
FILE_SIZE_LIMITS,
|
||||
SUPPORTED_MIME_TYPES,
|
||||
} from './file-utils';
|
||||
|
||||
describe('File Utilities', () => {
|
||||
describe('encodeFileToBase64', () => {
|
||||
it('should encode buffer to base64', () => {
|
||||
const buffer = Buffer.from('test content');
|
||||
const result = encodeFileToBase64(buffer);
|
||||
expect(result).toBe('dGVzdCBjb250ZW50');
|
||||
});
|
||||
|
||||
it('should encode string to base64', () => {
|
||||
const result = encodeFileToBase64('test content');
|
||||
expect(result).toBe('dGVzdCBjb250ZW50');
|
||||
});
|
||||
|
||||
it('should return base64 string as-is if already encoded', () => {
|
||||
const base64 = 'dGVzdCBjb250ZW50';
|
||||
const result = encodeFileToBase64(base64);
|
||||
expect(result).toBe(base64);
|
||||
});
|
||||
|
||||
it('should include MIME type in data URL format', () => {
|
||||
const buffer = Buffer.from('test');
|
||||
const result = encodeFileToBase64(buffer, 'application/json');
|
||||
expect(result).toContain('data:application/json;base64,');
|
||||
});
|
||||
});
|
||||
|
||||
describe('decodeBase64ToBuffer', () => {
|
||||
it('should decode base64 to buffer', () => {
|
||||
const base64 = 'dGVzdCBjb250ZW50';
|
||||
const buffer = decodeBase64ToBuffer(base64);
|
||||
expect(buffer.toString()).toBe('test content');
|
||||
});
|
||||
|
||||
it('should handle data URL format', () => {
|
||||
const dataUrl = 'data:application/json;base64,dGVzdCBjb250ZW50';
|
||||
const buffer = decodeBase64ToBuffer(dataUrl);
|
||||
expect(buffer.toString()).toBe('test content');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isBase64', () => {
|
||||
it('should return true for valid base64', () => {
|
||||
expect(isBase64('dGVzdCBjb250ZW50')).toBe(true);
|
||||
expect(isBase64('SGVsbG8gV29ybGQ=')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for invalid base64', () => {
|
||||
expect(isBase64('not base64!@#')).toBe(false);
|
||||
expect(isBase64('')).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle data URL format', () => {
|
||||
expect(isBase64('data:application/json;base64,dGVzdA==')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectMimeType', () => {
|
||||
it('should detect PDF from buffer', () => {
|
||||
const pdfBuffer = Buffer.from('%PDF-1.4\n');
|
||||
const mimeType = detectMimeType(pdfBuffer);
|
||||
expect(mimeType).toBe(SUPPORTED_MIME_TYPES.PDF);
|
||||
});
|
||||
|
||||
it('should detect PNG from buffer', () => {
|
||||
const pngBuffer = Buffer.from([0x89, 0x50, 0x4E, 0x47]);
|
||||
const mimeType = detectMimeType(pngBuffer);
|
||||
expect(mimeType).toBe(SUPPORTED_MIME_TYPES.PNG);
|
||||
});
|
||||
|
||||
it('should detect MIME type from filename', () => {
|
||||
const mimeType = detectMimeType(Buffer.from('test'), 'document.pdf');
|
||||
expect(mimeType).toBe(SUPPORTED_MIME_TYPES.PDF);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateBase64File', () => {
|
||||
it('should validate valid base64 file', () => {
|
||||
const base64 = encodeFileToBase64(Buffer.from('test content'));
|
||||
const result = validateBase64File(base64);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should reject invalid base64', () => {
|
||||
const result = validateBase64File('invalid base64!@#');
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should enforce size limits', () => {
|
||||
const largeBuffer = Buffer.alloc(FILE_SIZE_LIMITS.MEDIUM + 1);
|
||||
const base64 = encodeFileToBase64(largeBuffer);
|
||||
const result = validateBase64File(base64, {
|
||||
maxSize: FILE_SIZE_LIMITS.MEDIUM,
|
||||
});
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some(e => e.includes('exceeds maximum'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate MIME types', () => {
|
||||
const pdfBuffer = Buffer.from('%PDF-1.4\n');
|
||||
const base64 = encodeFileToBase64(pdfBuffer);
|
||||
const result = validateBase64File(base64, {
|
||||
allowedMimeTypes: [SUPPORTED_MIME_TYPES.PDF],
|
||||
});
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject disallowed MIME types', () => {
|
||||
const pdfBuffer = Buffer.from('%PDF-1.4\n');
|
||||
const base64 = encodeFileToBase64(pdfBuffer);
|
||||
const result = validateBase64File(base64, {
|
||||
allowedMimeTypes: [SUPPORTED_MIME_TYPES.PNG],
|
||||
});
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeFilename', () => {
|
||||
it('should sanitize unsafe characters', () => {
|
||||
const filename = '../../etc/passwd';
|
||||
const sanitized = sanitizeFilename(filename);
|
||||
expect(sanitized).not.toContain('/');
|
||||
expect(sanitized).not.toContain('..');
|
||||
});
|
||||
|
||||
it('should preserve safe characters', () => {
|
||||
const filename = 'document_123.pdf';
|
||||
const sanitized = sanitizeFilename(filename);
|
||||
expect(sanitized).toBe('document_123.pdf');
|
||||
});
|
||||
|
||||
it('should limit filename length', () => {
|
||||
const longFilename = 'a'.repeat(300) + '.pdf';
|
||||
const sanitized = sanitizeFilename(longFilename);
|
||||
expect(sanitized.length).toBeLessThanOrEqual(255);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateFileHash', () => {
|
||||
it('should calculate SHA256 hash', () => {
|
||||
const data = Buffer.from('test content');
|
||||
const hash = calculateFileHash(data);
|
||||
expect(hash).toHaveLength(64); // SHA256 produces 64 hex characters
|
||||
expect(typeof hash).toBe('string');
|
||||
});
|
||||
|
||||
it('should calculate SHA512 hash', () => {
|
||||
const data = Buffer.from('test content');
|
||||
const hash = calculateFileHash(data, 'sha512');
|
||||
expect(hash).toHaveLength(128); // SHA512 produces 128 hex characters
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
377
packages/auth/src/file-utils.ts
Normal file
377
packages/auth/src/file-utils.ts
Normal file
@@ -0,0 +1,377 @@
|
||||
/**
|
||||
* File handling utilities for Entra VerifiedID and other integrations
|
||||
* Provides base64 encoding/decoding, validation, and content type detection
|
||||
*/
|
||||
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
/**
|
||||
* Supported MIME types for document processing
|
||||
*/
|
||||
export const SUPPORTED_MIME_TYPES = {
|
||||
// Documents
|
||||
PDF: 'application/pdf',
|
||||
DOCX: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
DOC: 'application/msword',
|
||||
XLSX: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
XLS: 'application/vnd.ms-excel',
|
||||
// Images
|
||||
PNG: 'image/png',
|
||||
JPEG: 'image/jpeg',
|
||||
JPG: 'image/jpg',
|
||||
GIF: 'image/gif',
|
||||
WEBP: 'image/webp',
|
||||
// Text
|
||||
TEXT: 'text/plain',
|
||||
JSON: 'application/json',
|
||||
XML: 'application/xml',
|
||||
// Archives
|
||||
ZIP: 'application/zip',
|
||||
TAR: 'application/x-tar',
|
||||
GZIP: 'application/gzip',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Maximum file size limits (in bytes)
|
||||
*/
|
||||
export const FILE_SIZE_LIMITS = {
|
||||
SMALL: 1024 * 1024, // 1 MB
|
||||
MEDIUM: 10 * 1024 * 1024, // 10 MB
|
||||
LARGE: 100 * 1024 * 1024, // 100 MB
|
||||
XLARGE: 500 * 1024 * 1024, // 500 MB
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* File validation options
|
||||
*/
|
||||
export interface FileValidationOptions {
|
||||
maxSize?: number;
|
||||
allowedMimeTypes?: string[];
|
||||
requireMimeType?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* File encoding result
|
||||
*/
|
||||
export interface FileEncodingResult {
|
||||
base64: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
hash: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* File validation result
|
||||
*/
|
||||
export interface FileValidationResult {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
mimeType?: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a file buffer or string to base64
|
||||
*/
|
||||
export function encodeFileToBase64(
|
||||
file: Buffer | string | Uint8Array,
|
||||
mimeType?: string
|
||||
): string {
|
||||
let buffer: Buffer;
|
||||
|
||||
if (typeof file === 'string') {
|
||||
// If it's already base64, validate and return
|
||||
if (isBase64(file)) {
|
||||
return file;
|
||||
}
|
||||
// Otherwise, convert string to buffer
|
||||
buffer = Buffer.from(file, 'utf-8');
|
||||
} else if (file instanceof Uint8Array) {
|
||||
buffer = Buffer.from(file);
|
||||
} else {
|
||||
buffer = file;
|
||||
}
|
||||
|
||||
const base64 = buffer.toString('base64');
|
||||
|
||||
// If MIME type is provided, return as data URL
|
||||
if (mimeType) {
|
||||
return `data:${mimeType};base64,${base64}`;
|
||||
}
|
||||
|
||||
return base64;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode base64 string to buffer
|
||||
*/
|
||||
export function decodeBase64ToBuffer(base64: string): Buffer {
|
||||
// Remove data URL prefix if present
|
||||
const base64Data = base64.includes(',')
|
||||
? base64.split(',')[1]
|
||||
: base64;
|
||||
|
||||
return Buffer.from(base64Data, 'base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string is valid base64
|
||||
*/
|
||||
export function isBase64(str: string): boolean {
|
||||
if (!str || str.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Remove data URL prefix if present
|
||||
const base64Data = str.includes(',')
|
||||
? str.split(',')[1]
|
||||
: str;
|
||||
|
||||
// Base64 regex pattern
|
||||
const base64Regex = /^[A-Za-z0-9+/]*={0,2}$/;
|
||||
|
||||
if (!base64Regex.test(base64Data)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check length is multiple of 4
|
||||
if (base64Data.length % 4 !== 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Try to decode
|
||||
try {
|
||||
Buffer.from(base64Data, 'base64');
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect MIME type from buffer or file extension
|
||||
*/
|
||||
export function detectMimeType(
|
||||
data: Buffer | string,
|
||||
filename?: string
|
||||
): string {
|
||||
// Try to detect from file extension first
|
||||
if (filename) {
|
||||
const extension = filename.split('.').pop()?.toLowerCase();
|
||||
const mimeType = getMimeTypeFromExtension(extension || '');
|
||||
if (mimeType) {
|
||||
return mimeType;
|
||||
}
|
||||
}
|
||||
|
||||
// Try to detect from buffer magic bytes
|
||||
if (data instanceof Buffer && data.length > 0) {
|
||||
const mimeType = detectMimeTypeFromBuffer(data);
|
||||
if (mimeType) {
|
||||
return mimeType;
|
||||
}
|
||||
}
|
||||
|
||||
// Default to application/octet-stream
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get MIME type from file extension
|
||||
*/
|
||||
function getMimeTypeFromExtension(extension: string): string | null {
|
||||
const mimeTypes: Record<string, string> = {
|
||||
// Documents
|
||||
pdf: SUPPORTED_MIME_TYPES.PDF,
|
||||
docx: SUPPORTED_MIME_TYPES.DOCX,
|
||||
doc: SUPPORTED_MIME_TYPES.DOC,
|
||||
xlsx: SUPPORTED_MIME_TYPES.XLSX,
|
||||
xls: SUPPORTED_MIME_TYPES.XLS,
|
||||
// Images
|
||||
png: SUPPORTED_MIME_TYPES.PNG,
|
||||
jpg: SUPPORTED_MIME_TYPES.JPG,
|
||||
jpeg: SUPPORTED_MIME_TYPES.JPEG,
|
||||
gif: SUPPORTED_MIME_TYPES.GIF,
|
||||
webp: SUPPORTED_MIME_TYPES.WEBP,
|
||||
// Text
|
||||
txt: SUPPORTED_MIME_TYPES.TEXT,
|
||||
json: SUPPORTED_MIME_TYPES.JSON,
|
||||
xml: SUPPORTED_MIME_TYPES.XML,
|
||||
// Archives
|
||||
zip: SUPPORTED_MIME_TYPES.ZIP,
|
||||
tar: SUPPORTED_MIME_TYPES.TAR,
|
||||
gz: SUPPORTED_MIME_TYPES.GZIP,
|
||||
};
|
||||
|
||||
return mimeTypes[extension.toLowerCase()] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect MIME type from buffer magic bytes
|
||||
*/
|
||||
function detectMimeTypeFromBuffer(buffer: Buffer): string | null {
|
||||
// Check magic bytes (file signatures)
|
||||
if (buffer.length < 4) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const header = buffer.slice(0, 12);
|
||||
|
||||
// PDF
|
||||
if (header.slice(0, 4).toString() === '%PDF') {
|
||||
return SUPPORTED_MIME_TYPES.PDF;
|
||||
}
|
||||
|
||||
// PNG
|
||||
if (header[0] === 0x89 && header[1] === 0x50 && header[2] === 0x4E && header[3] === 0x47) {
|
||||
return SUPPORTED_MIME_TYPES.PNG;
|
||||
}
|
||||
|
||||
// JPEG
|
||||
if (header[0] === 0xFF && header[1] === 0xD8 && header[2] === 0xFF) {
|
||||
return SUPPORTED_MIME_TYPES.JPEG;
|
||||
}
|
||||
|
||||
// GIF
|
||||
if (header.slice(0, 3).toString() === 'GIF') {
|
||||
return SUPPORTED_MIME_TYPES.GIF;
|
||||
}
|
||||
|
||||
// ZIP (also detects DOCX, XLSX which are ZIP-based)
|
||||
if (header[0] === 0x50 && header[1] === 0x4B) {
|
||||
// Check if it's a DOCX or XLSX by checking internal structure
|
||||
// For simplicity, return ZIP - caller can refine based on filename
|
||||
return SUPPORTED_MIME_TYPES.ZIP;
|
||||
}
|
||||
|
||||
// JSON (starts with { or [)
|
||||
const text = buffer.slice(0, 100).toString('utf-8').trim();
|
||||
if (text.startsWith('{') || text.startsWith('[')) {
|
||||
try {
|
||||
JSON.parse(text);
|
||||
return SUPPORTED_MIME_TYPES.JSON;
|
||||
} catch {
|
||||
// Not valid JSON
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a base64-encoded file
|
||||
*/
|
||||
export function validateBase64File(
|
||||
base64: string,
|
||||
options: FileValidationOptions = {}
|
||||
): FileValidationResult {
|
||||
const errors: string[] = [];
|
||||
|
||||
// Check if it's valid base64
|
||||
if (!isBase64(base64)) {
|
||||
errors.push('Invalid base64 encoding');
|
||||
return { valid: false, errors };
|
||||
}
|
||||
|
||||
// Decode to get size
|
||||
let buffer: Buffer;
|
||||
try {
|
||||
buffer = decodeBase64ToBuffer(base64);
|
||||
} catch (error) {
|
||||
errors.push(`Failed to decode base64: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return { valid: false, errors };
|
||||
}
|
||||
|
||||
const size = buffer.length;
|
||||
|
||||
// Check size limit
|
||||
if (options.maxSize && size > options.maxSize) {
|
||||
errors.push(`File size (${size} bytes) exceeds maximum allowed size (${options.maxSize} bytes)`);
|
||||
}
|
||||
|
||||
// Detect and validate MIME type
|
||||
let mimeType: string | undefined;
|
||||
try {
|
||||
mimeType = detectMimeTypeFromBuffer(buffer);
|
||||
} catch {
|
||||
// MIME type detection failed, but not critical
|
||||
}
|
||||
|
||||
if (options.requireMimeType && !mimeType) {
|
||||
errors.push('Could not detect file MIME type');
|
||||
}
|
||||
|
||||
if (options.allowedMimeTypes && mimeType) {
|
||||
if (!options.allowedMimeTypes.includes(mimeType)) {
|
||||
errors.push(`MIME type ${mimeType} is not allowed. Allowed types: ${options.allowedMimeTypes.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
mimeType,
|
||||
size,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode file with full metadata
|
||||
*/
|
||||
export function encodeFileWithMetadata(
|
||||
file: Buffer | string | Uint8Array,
|
||||
filename?: string,
|
||||
mimeType?: string
|
||||
): FileEncodingResult {
|
||||
let buffer: Buffer;
|
||||
|
||||
if (typeof file === 'string') {
|
||||
if (isBase64(file)) {
|
||||
buffer = decodeBase64ToBuffer(file);
|
||||
} else {
|
||||
buffer = Buffer.from(file, 'utf-8');
|
||||
}
|
||||
} else if (file instanceof Uint8Array) {
|
||||
buffer = Buffer.from(file);
|
||||
} else {
|
||||
buffer = file;
|
||||
}
|
||||
|
||||
const detectedMimeType = mimeType || detectMimeType(buffer, filename);
|
||||
const base64 = encodeFileToBase64(buffer, detectedMimeType);
|
||||
const hash = createHash('sha256').update(buffer).digest('hex');
|
||||
|
||||
return {
|
||||
base64,
|
||||
mimeType: detectedMimeType,
|
||||
size: buffer.length,
|
||||
hash,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize filename for safe storage
|
||||
*/
|
||||
export function sanitizeFilename(filename: string): string {
|
||||
// Remove path components
|
||||
const basename = filename.split(/[/\\]/).pop() || filename;
|
||||
|
||||
// Remove or replace unsafe characters
|
||||
return basename
|
||||
.replace(/[^a-zA-Z0-9._-]/g, '_')
|
||||
.replace(/_{2,}/g, '_')
|
||||
.replace(/^_+|_+$/g, '')
|
||||
.substring(0, 255); // Limit length
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate file hash for integrity verification
|
||||
*/
|
||||
export function calculateFileHash(data: Buffer | string, algorithm: 'sha256' | 'sha512' = 'sha256'): string {
|
||||
const buffer = typeof data === 'string'
|
||||
? Buffer.from(data, 'utf-8')
|
||||
: data;
|
||||
|
||||
return createHash(algorithm).update(buffer).digest('hex');
|
||||
}
|
||||
|
||||
@@ -8,4 +8,5 @@ export * from './eidas';
|
||||
export * from './entra-verifiedid';
|
||||
export * from './azure-logic-apps';
|
||||
export * from './eidas-entra-bridge';
|
||||
export * from './file-utils';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user