| | | 1 | | import type { SecretClient } from '@azure/keyvault-secrets'; |
| | | 2 | | import { injectable } from 'inversify'; |
| | | 3 | | import { EnvironmentVariable } from '../../domain/EnvironmentVariable.js'; |
| | | 4 | | import { |
| | | 5 | | InvalidArgumentError, |
| | | 6 | | SecretOperationError, |
| | | 7 | | } from '../../domain/errors/DomainErrors.js'; |
| | | 8 | | import type { ISecretProvider } from '../../domain/ports/ISecretProvider.js'; |
| | | 9 | | |
| | 9 | 10 | | @injectable() |
| | 9 | 11 | | export class AzureKeyVaultSecretProvider implements ISecretProvider { |
| | | 12 | | private client: SecretClient; |
| | 38 | 13 | | private normalizedNameRegistry = new Map<string, string>(); |
| | | 14 | | |
| | | 15 | | constructor(client: SecretClient) { |
| | 38 | 16 | | this.client = client; |
| | | 17 | | } |
| | | 18 | | |
| | | 19 | | async getSecret(name: string): Promise<string | undefined> { |
| | 26 | 20 | | const secretName = this.resolveSecretName(name); |
| | 26 | 21 | | try { |
| | 26 | 22 | | const secret = await this.client.getSecret(secretName); |
| | 16 | 23 | | return secret?.value ?? undefined; |
| | | 24 | | } catch (error) { |
| | 4 | 25 | | if ( |
| | | 26 | | typeof error === 'object' && |
| | | 27 | | error !== null && |
| | | 28 | | 'statusCode' in error && |
| | | 29 | | error.statusCode === 404 |
| | | 30 | | ) { |
| | 2 | 31 | | return undefined; |
| | | 32 | | } |
| | | 33 | | const errorMessage = |
| | 2 | 34 | | error instanceof Error ? error.message : String(error); |
| | 4 | 35 | | throw new SecretOperationError( |
| | | 36 | | `Failed to get secret ${EnvironmentVariable.maskSecretPath(name)}: ${errorMessage}`, |
| | | 37 | | ); |
| | | 38 | | } |
| | | 39 | | } |
| | | 40 | | |
| | | 41 | | async setSecret(name: string, value: string): Promise<void> { |
| | 9 | 42 | | const secretName = this.resolveSecretName(name); |
| | 9 | 43 | | await this.client.setSecret(secretName, value); |
| | | 44 | | } |
| | | 45 | | |
| | | 46 | | /** |
| | | 47 | | * Validates that the secret name meets Azure Key Vault naming constraints. |
| | | 48 | | * @see https://learn.microsoft.com/en-us/azure/key-vault/general/about-keys-secrets-certificates#objects-identifiers- |
| | | 49 | | */ |
| | | 50 | | private validateSecretName(name: string): void { |
| | 35 | 51 | | if (name.trim().length === 0) { |
| | 1 | 52 | | throw new InvalidArgumentError( |
| | | 53 | | 'Invalid secret name: name cannot be empty or whitespace-only.', |
| | | 54 | | ); |
| | | 55 | | } |
| | 34 | 56 | | if (/[^a-zA-Z0-9\-_/]/.test(name)) { |
| | 2 | 57 | | throw new InvalidArgumentError( |
| | | 58 | | `Invalid secret name '${name}': contains characters not allowed` + |
| | | 59 | | ' by Azure Key Vault. Only alphanumeric characters,' + |
| | | 60 | | ' hyphens, slashes, and underscores are accepted.', |
| | | 61 | | ); |
| | | 62 | | } |
| | | 63 | | } |
| | | 64 | | |
| | | 65 | | private resolveSecretName(originalName: string): string { |
| | 35 | 66 | | this.validateSecretName(originalName); |
| | 35 | 67 | | const normalized = this.normalizeSecretName(originalName); |
| | 35 | 68 | | if (normalized.length > 127) { |
| | 1 | 69 | | throw new InvalidArgumentError( |
| | | 70 | | `Invalid secret name '${originalName}': normalized name '${normalized}' exceeds the 127-character limit for Azur |
| | | 71 | | ); |
| | | 72 | | } |
| | 31 | 73 | | const existing = this.normalizedNameRegistry.get(normalized); |
| | 31 | 74 | | if (existing !== undefined && existing !== originalName) { |
| | 3 | 75 | | throw new SecretOperationError( |
| | | 76 | | `Secret name collision: '${originalName}' and '${existing}' ` + |
| | | 77 | | `both normalize to '${normalized}'. Use distinct ` + |
| | | 78 | | 'Key Vault-compatible names in your map file ' + |
| | | 79 | | 'when targeting Azure.', |
| | | 80 | | ); |
| | | 81 | | } |
| | 28 | 82 | | this.normalizedNameRegistry.set(normalized, originalName); |
| | 28 | 83 | | return normalized; |
| | | 84 | | } |
| | | 85 | | |
| | | 86 | | // Azure Key Vault secret names: 1-127 chars, alphanumeric + hyphens, start with letter |
| | | 87 | | private normalizeSecretName(name: string): string { |
| | | 88 | | // Remove leading slashes |
| | 32 | 89 | | let normalized = name.replace(/^\/+/, ''); |
| | | 90 | | |
| | | 91 | | // Replace slashes and underscores with hyphens |
| | 32 | 92 | | normalized = normalized.replace(/[/_]/g, '-'); |
| | | 93 | | |
| | | 94 | | // Lowercase to match Azure Key Vault case-insensitivity |
| | 32 | 95 | | normalized = normalized.toLowerCase(); |
| | | 96 | | |
| | | 97 | | // Remove invalid characters |
| | 32 | 98 | | normalized = normalized.replace(/[^a-zA-Z0-9-]/g, ''); |
| | | 99 | | |
| | | 100 | | // Remove consecutive hyphens |
| | 32 | 101 | | normalized = normalized.replace(/-+/g, '-'); |
| | | 102 | | |
| | | 103 | | // Remove leading/trailing hyphens |
| | 32 | 104 | | normalized = normalized.replace(/^-+|-+$/g, ''); |
| | | 105 | | |
| | | 106 | | // Ensure starts with a letter |
| | 32 | 107 | | if (normalized.length > 0 && !/^[a-zA-Z]/.test(normalized)) { |
| | 1 | 108 | | normalized = `secret-${normalized}`; |
| | | 109 | | } |
| | | 110 | | |
| | | 111 | | // Default name if empty |
| | 32 | 112 | | if (normalized.length === 0) { |
| | 0 | 113 | | normalized = 'secret'; |
| | | 114 | | } |
| | | 115 | | |
| | 32 | 116 | | return normalized; |
| | | 117 | | } |
| | | 118 | | } |