diff --git a/src/lib/components/provider-icons/AzureKeyVault.svelte b/src/lib/components/provider-icons/AzureKeyVault.svelte new file mode 100644 index 0000000..a6decc8 --- /dev/null +++ b/src/lib/components/provider-icons/AzureKeyVault.svelte @@ -0,0 +1,20 @@ + + + + + + + diff --git a/src/lib/components/provider-icons/index.ts b/src/lib/components/provider-icons/index.ts index 55ab428..a0a2b6b 100644 --- a/src/lib/components/provider-icons/index.ts +++ b/src/lib/components/provider-icons/index.ts @@ -6,6 +6,7 @@ import HashiCorpVault from './HashiCorpVault.svelte'; import Doppler from './Doppler.svelte'; import Bitwarden from './Bitwarden.svelte'; import ProtonPass from './ProtonPass.svelte'; +import AzureKeyVault from './AzureKeyVault.svelte'; /** Brand icon for a secret-provider type. Falls back to a generic key icon. */ export function getProviderIcon(type: string): Component { @@ -23,6 +24,8 @@ export function getProviderIcon(type: string): Component { return Bitwarden; case 'proton': return ProtonPass; + case 'azure-kv': + return AzureKeyVault; default: return KeyRound; // any future provider until it has a brand icon } diff --git a/src/lib/server/secretproviders/azure-kv.ts b/src/lib/server/secretproviders/azure-kv.ts new file mode 100644 index 0000000..01d40e5 --- /dev/null +++ b/src/lib/server/secretproviders/azure-kv.ts @@ -0,0 +1,203 @@ +/** + * Azure Key Vault provider. + * + * Authenticates a service principal (app registration) via OAuth2 + * client-credentials against Azure AD, then reads secrets over the Key Vault + * REST API. Two resolution modes: + * - Bulk pull: every secret in the vault, keyed by secret name. + * - Inline references: `azurekv://`, one secret at a time. + * + * Pure HTTP (undici) - no Azure SDK. The client secret stays in the (encrypted) + * config; the short-lived bearer token is cached in memory per service principal. + */ + +import { request } from 'undici'; +import type { AzureKvConfig, SecretProvider, TestConnectionResult } from './shared'; +import { assertSafeProviderHost, isJsonResponse } from './shared'; + +const API_VERSION = '7.4'; +const AZURE_REF_PREFIX = 'azurekv://'; +// Key Vault secret names are 1-127 chars, alphanumeric and dashes only. +const AZURE_REF_RE = /^azurekv:\/\/[A-Za-z0-9-]{1,127}$/; + +/** Strip a trailing slash from the vault URI so path joins are clean. */ +function baseUrl(config: AzureKvConfig): string { + return config.vaultUri.replace(/\/+$/, ''); +} + +/** The secret name from an `azurekv://` reference. */ +function refName(ref: string): string { + return ref.slice(AZURE_REF_PREFIX.length); +} + +interface CachedToken { + token: string; + /** Epoch ms after which the cached token is treated as expired. */ + expiresAt: number; +} +// Keyed by tenant + client + vault so two providers never share a token. +const tokenCache = new Map(); +const TOKEN_REFRESH_MARGIN_MS = 60_000; + +function tokenCacheKey(config: AzureKvConfig): string { + return `${config.tenantId}::${config.clientId}::${baseUrl(config)}`; +} + +/** Clear the AD token cache (called on provider config change / deletion). */ +export function clearAzureKvTokenCache(): void { + tokenCache.clear(); +} + +/** + * Acquire an AD access token for the Key Vault scope via client-credentials. + * Cached until shortly before expiry. Throws on auth failure. + */ +async function getAccessToken(config: AzureKvConfig): Promise { + const key = tokenCacheKey(config); + const now = Date.now(); + const hit = tokenCache.get(key); + if (hit && hit.expiresAt > now) return hit.token; + + // Azure AD lives on login.microsoftonline.com - a fixed, public host. The + // vaultUri IS user-controlled, so it is SSRF-guarded before any request. + const tokenUrl = `https://login.microsoftonline.com/${encodeURIComponent(config.tenantId)}/oauth2/v2.0/token`; + const form = new URLSearchParams({ + grant_type: 'client_credentials', + client_id: config.clientId, + client_secret: config.clientSecret, + scope: 'https://vault.azure.net/.default' + }); + + const { statusCode, body } = await request(tokenUrl, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: form.toString() + }); + const text = await body.text(); + if (statusCode < 200 || statusCode >= 300 || !isJsonResponse(text)) { + let detail = ''; + try { + detail = ((JSON.parse(text) as { error_description?: string }).error_description ?? '') + .split('\n')[0] + ?.trim() ?? ''; + } catch { + // non-JSON body; leave detail empty + } + throw new Error( + `Azure Key Vault: authentication failed (HTTP ${statusCode}${detail ? `: ${detail}` : ''})` + ); + } + const data = JSON.parse(text) as { access_token?: string; expires_in?: number }; + if (!data.access_token) throw new Error('Azure Key Vault: no access token returned'); + + const ttlMs = (typeof data.expires_in === 'number' ? data.expires_in : 3600) * 1000; + tokenCache.set(key, { token: data.access_token, expiresAt: now + ttlMs - TOKEN_REFRESH_MARGIN_MS }); + return data.access_token; +} + +/** Read one secret's value by name. Returns null when the secret is absent. */ +async function readSecret(config: AzureKvConfig, token: string, name: string): Promise { + const { statusCode, body } = await request( + `${baseUrl(config)}/secrets/${encodeURIComponent(name)}?api-version=${API_VERSION}`, + { method: 'GET', headers: { authorization: `Bearer ${token}` } } + ); + const text = await body.text(); + if (statusCode === 404) return null; + if (statusCode < 200 || statusCode >= 300 || !isJsonResponse(text)) { + throw new Error(`Azure Key Vault: failed to read secret "${name}" (HTTP ${statusCode})`); + } + const data = JSON.parse(text) as { value?: string }; + return data.value ?? null; +} + +/** List every secret name in the vault (paginated), capped for safety. */ +async function listSecretNames(config: AzureKvConfig, token: string): Promise { + const names: string[] = []; + let url: string | null = `${baseUrl(config)}/secrets?api-version=${API_VERSION}&maxresults=25`; + while (url && names.length < 500) { + const { statusCode, body } = await request(url, { + method: 'GET', + headers: { authorization: `Bearer ${token}` } + }); + const text = await body.text(); + if (statusCode < 200 || statusCode >= 300 || !isJsonResponse(text)) { + throw new Error(`Azure Key Vault: cannot list secrets (HTTP ${statusCode})`); + } + const data = JSON.parse(text) as { value?: { id: string }[]; nextLink?: string | null }; + for (const item of data.value ?? []) { + const name = item.id.split('/').pop(); + if (name) names.push(name); + } + url = data.nextLink ?? null; + } + return names; +} + +export const azureKvProvider: SecretProvider = { + type: 'azure-kv', + label: 'Azure Key Vault', + supportsReferences: true, + supportsBulk: true, + + isReference(value: unknown): value is string { + return typeof value === 'string' && AZURE_REF_RE.test(value.trim()); + }, + + async testConnection(config: AzureKvConfig): Promise { + try { + assertSafeProviderHost(config.vaultUri, 'Azure Key Vault'); + const token = await getAccessToken(config); + // A list call proves both auth AND that the vault URI is reachable/authorized. + await listSecretNames(config, token); + return { ok: true }; + } catch (e: unknown) { + return { ok: false, error: e instanceof Error ? e.message : String(e) }; + } + }, + + async resolveSecretReferences( + config: AzureKvConfig, + refs: string[], + logPrefix = '[Azure Key Vault]' + ): Promise> { + const result = new Map(); + if (refs.length === 0) return result; + assertSafeProviderHost(config.vaultUri, 'Azure Key Vault'); + const token = await getAccessToken(config); + // Unique names only - two refs to the same secret cost one call. + const names = new Set(refs.map(refName)); + const values = new Map(); + await Promise.all( + [...names].map(async (name) => { + try { + const v = await readSecret(config, token, name); + if (v !== null) values.set(name, v); + else console.warn(`${logPrefix} Skipping azurekv://${name}: secret not found`); + } catch (e) { + console.warn(`${logPrefix} Skipping azurekv://${name}: ${e instanceof Error ? e.message : e}`); + } + }) + ); + for (const ref of refs) { + const v = values.get(refName(ref)); + if (v !== undefined) result.set(ref, v); + } + return result; + }, + + // The selector is ignored: a Key Vault has no sub-paths, so a bulk pull is + // always "every secret in the vault". + async resolveBulk(config: AzureKvConfig, _selector: string): Promise> { + assertSafeProviderHost(config.vaultUri, 'Azure Key Vault'); + const token = await getAccessToken(config); + const names = await listSecretNames(config, token); + const result: Record = {}; + await Promise.all( + names.map(async (name) => { + const v = await readSecret(config, token, name); + if (v !== null) result[name] = v; + }) + ); + return result; + } +}; diff --git a/src/lib/server/secretproviders/index.ts b/src/lib/server/secretproviders/index.ts index 53d8591..4e12b1e 100644 --- a/src/lib/server/secretproviders/index.ts +++ b/src/lib/server/secretproviders/index.ts @@ -20,6 +20,7 @@ import { vaultProvider } from './vault'; import { dopplerProvider } from './doppler'; import { bitwardenProvider } from './bitwarden'; import { protonProvider } from './proton'; +import { azureKvProvider } from './azure-kv'; // Registered providers. Adding a new backend means dropping a file in this // directory and registering it here; each implements the SecretProvider @@ -31,7 +32,8 @@ const providers: Record = { [vaultProvider.type]: vaultProvider as SecretProvider, [dopplerProvider.type]: dopplerProvider as SecretProvider, [bitwardenProvider.type]: bitwardenProvider as SecretProvider, - [protonProvider.type]: protonProvider as SecretProvider + [protonProvider.type]: protonProvider as SecretProvider, + [azureKvProvider.type]: azureKvProvider as SecretProvider }; /** Returns the provider for a stored type, or undefined if unknown. */ diff --git a/src/lib/types.ts b/src/lib/types.ts index 85da203..55c5e21 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -103,7 +103,7 @@ export interface NetworkInfo { export interface StackInfo { name: string; services: string[]; - status: 'running' | 'partial' | 'stopped'; + status: 'running' | 'partial' | 'restarting' | 'stopped'; containers: Array<{ id: string; name: string; diff --git a/src/routes/settings/secrets/ProviderModal.svelte b/src/routes/settings/secrets/ProviderModal.svelte index 0c93868..e7d41b9 100644 --- a/src/routes/settings/secrets/ProviderModal.svelte +++ b/src/routes/settings/secrets/ProviderModal.svelte @@ -29,6 +29,7 @@ { value: 'doppler', label: 'Doppler' }, { value: 'bitwarden', label: 'Bitwarden Secrets Manager' }, { value: 'proton', label: 'Proton Pass' }, + { value: 'azure-kv', label: 'Azure Key Vault' }, ]; // Config fields per provider type, matching the config shapes in @@ -71,6 +72,12 @@ proton: [ { key: 'token', label: 'Personal access token', type: 'password', required: true, placeholder: 'pst_...::...', hint: 'A Proton Pass personal access token (pst_...) used by the operator-installed pass-cli.' }, ], + 'azure-kv': [ + { key: 'vaultUri', label: 'Vault URI', type: 'text', required: true, placeholder: 'https://my-vault.vault.azure.net', hint: 'The Key Vault URI (from the vault Overview page).' }, + { key: 'tenantId', label: 'Tenant ID', type: 'text', required: true, placeholder: 'directory (tenant) ID', hint: 'Azure AD tenant (directory) ID of the app registration.' }, + { key: 'clientId', label: 'Client ID', type: 'text', required: true, placeholder: 'application (client) ID', hint: 'The service-principal (app registration) client ID.' }, + { key: 'clientSecret', label: 'Client secret', type: 'password', required: true, placeholder: 'app registration client secret', hint: 'A client secret for the app registration, with Get/List secret permission on the vault.' }, + ], }; export function providerTypeLabel(type: string): string { @@ -107,6 +114,11 @@ label: 'Vault', placeholder: 'Proton Pass vault name', hint: 'Bulk-load every item from this Proton Pass vault. Leave blank to inject only inline pass:// references.' + }, + 'azure-kv': { + label: 'Key Vault', + placeholder: 'any value enables bulk pull', + hint: 'Bulk-load every secret in the vault. Set any value to enable it; leave blank to inject only inline azurekv:// references.' } };