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