azure key vault as a secret provider (bulk pull or inline azurekv:// references)

This commit is contained in:
Jarek Krochmalski committed 2026-08-22 09:09:41 +02:00
1 parent a760680b8d
commit e23936b4df
6 files changed
+242 -2

No files matched your search

@@ -0,0 +1,20 @@
<script lang="ts">
let { class: className = 'w-4 h-4' }: { class?: string } = $props();
</script>
<!-- Azure Key Vault, monochrome (currentColor). Ring + key, toned via opacity. -->
<svg
viewBox="0 0 18 18"
xmlns="http://www.w3.org/2000/svg"
class={className}
fill="currentColor"
aria-label="Azure Key Vault"
>
<path
d="M9,.5A8.5,8.5,0,1,0,17.5,9,8.51,8.51,0,0,0,9,.5ZM9,16.34A7.34,7.34,0,1,1,16.34,9,7.34,7.34,0,0,1,9,16.34Z"
style="opacity:.55"
/>
<path
d="M13.44,7.33a1.84,1.84,0,0,0,0-2.59h0L10.29,1.58a1.83,1.83,0,0,0-2.58,0h0L4.56,4.74a1.84,1.84,0,0,0,0,2.59L7.18,10a.51.51,0,0,1,.15.36v4.88a.63.63,0,0,0,.18.44l1.2,1.2a.41.41,0,0,0,.58,0l1.16-1.16h0l.68-.68a.25.25,0,0,0,0-.34l-.49-.49a.27.27,0,0,1,0-.37l.49-.49a.25.25,0,0,0,0-.34l-.49-.49a.27.27,0,0,1,0-.37l.49-.49a.25.25,0,0,0,0-.34l-.68-.69v-.25ZM9,2.35A1,1,0,0,1,9,4.42,1,1,0,1,1,9,2.35Z"
/>
</svg>
@@ -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
}
+203
View File
@@ -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://<secret-name>`, 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://<name>` 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<string, CachedToken>();
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<string> {
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<string | null> {
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<string[]> {
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<AzureKvConfig> = {
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<TestConnectionResult> {
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<Map<string, string>> {
const result = new Map<string, string>();
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<string, string>();
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<Record<string, string>> {
assertSafeProviderHost(config.vaultUri, 'Azure Key Vault');
const token = await getAccessToken(config);
const names = await listSecretNames(config, token);
const result: Record<string, string> = {};
await Promise.all(
names.map(async (name) => {
const v = await readSecret(config, token, name);
if (v !== null) result[name] = v;
})
);
return result;
}
};
+3 -1
View File
@@ -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<string, SecretProvider> = {
[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. */
+1 -1
View File
@@ -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;
@@ -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.'
}
};
</script>