feat(openbao): kubernetes ServiceAccount auth — no static token in DB
Some checks failed
Some checks failed
Why: requiring a static OpenBao root token to live (even once-bootstrap) on
the plaintext backend is the weakest link in the chain. With the bao-side
Kubernetes auth method enabled, mcpd's pod can authenticate using its own
projected SA token, exchange it for a short-lived Vault client token, and
keep the database free of any vault credentials at all.
Driver changes (src/mcpd/src/services/secret-backends/openbao.ts):
- New `OpenBaoConfig.auth = 'token' | 'kubernetes'`. Defaults to 'token' so
existing rows keep working. Both shapes share url + mount + pathPrefix +
namespace; auth-specific fields are mutually exclusive in the config schema.
- Kubernetes auth flow: read JWT from /var/run/secrets/.../token, POST to
/v1/auth/<authMount>/login {role, jwt}, cache the returned client_token
for `lease_duration - 60s` (grace window), then re-login.
- One-shot 403-retry: if a request comes back 403 (revoked / clock skew),
purge cache and retry the original request once with a fresh login.
- Reads + writes go through the same getToken() path so token-auth is
unchanged for existing deployments.
CLI (src/cli/src/commands/create.ts):
- `mcpctl create secretbackend bao --type openbao --auth kubernetes \
--url https://bao.example:8200 --role mcpctl`
- Optional `--auth-mount` (default 'kubernetes') + `--sa-token-path` (default
the standard projected-token path) for non-default deployments.
- Token-auth path unchanged: `--auth token --token-secret SECRET/KEY`
(or omit `--auth` since 'token' is the default).
Validation (factory.ts) gates on the auth strategy: each path enforces its
own required fields and produces a clear error if misconfigured.
Tests: 6 new k8s-auth unit cases (login wire shape, lease-based caching,
custom authMount, 403-on-login, missing-role rejection, missing-tokenSecretRef
rejection). Full suite 1859/1859. Completions regenerated for the new flags.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -188,7 +188,7 @@ _mcpctl() {
|
||||
COMPREPLY=($(compgen -W "--type --model --url --tier --description --api-key-ref --extra --force -h --help" -- "$cur"))
|
||||
;;
|
||||
secretbackend)
|
||||
COMPREPLY=($(compgen -W "--type --description --default --url --namespace --mount --path-prefix --token-secret --config --force -h --help" -- "$cur"))
|
||||
COMPREPLY=($(compgen -W "--type --description --default --url --namespace --mount --path-prefix --auth --token-secret --role --auth-mount --sa-token-path --config --force -h --help" -- "$cur"))
|
||||
;;
|
||||
project)
|
||||
COMPREPLY=($(compgen -W "-d --description --proxy-model --prompt --llm --llm-model --gated --no-gated --server --force -h --help" -- "$cur"))
|
||||
|
||||
@@ -336,7 +336,11 @@ complete -c mcpctl -n "__mcpctl_subcmd_active create secretbackend" -l url -d 'o
|
||||
complete -c mcpctl -n "__mcpctl_subcmd_active create secretbackend" -l namespace -d 'openbao: X-Vault-Namespace header value' -x
|
||||
complete -c mcpctl -n "__mcpctl_subcmd_active create secretbackend" -l mount -d 'openbao: KV v2 mount point (default: secret)' -x
|
||||
complete -c mcpctl -n "__mcpctl_subcmd_active create secretbackend" -l path-prefix -d 'openbao: path prefix under mount (default: mcpctl)' -x
|
||||
complete -c mcpctl -n "__mcpctl_subcmd_active create secretbackend" -l token-secret -d 'openbao: token secret reference in SECRET/KEY form (e.g. bao-creds/token)' -x
|
||||
complete -c mcpctl -n "__mcpctl_subcmd_active create secretbackend" -l auth -d 'openbao: auth method — \'token\' (default) or \'kubernetes\'' -x
|
||||
complete -c mcpctl -n "__mcpctl_subcmd_active create secretbackend" -l token-secret -d 'openbao token auth: token secret reference in SECRET/KEY form (e.g. bao-creds/token)' -x
|
||||
complete -c mcpctl -n "__mcpctl_subcmd_active create secretbackend" -l role -d 'openbao kubernetes auth: vault role to login as (e.g. \'mcpctl\')' -x
|
||||
complete -c mcpctl -n "__mcpctl_subcmd_active create secretbackend" -l auth-mount -d 'openbao kubernetes auth: vault auth method mount path (default: \'kubernetes\')' -x
|
||||
complete -c mcpctl -n "__mcpctl_subcmd_active create secretbackend" -l sa-token-path -d 'openbao kubernetes auth: filesystem path to projected SA token (default: \'/var/run/secrets/kubernetes.io/serviceaccount/token\')' -x
|
||||
complete -c mcpctl -n "__mcpctl_subcmd_active create secretbackend" -l config -d 'Extra config as key=value (repeat for multiple)' -x
|
||||
complete -c mcpctl -n "__mcpctl_subcmd_active create secretbackend" -l force -d 'Update if already exists'
|
||||
|
||||
|
||||
@@ -319,7 +319,11 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
|
||||
.option('--namespace <ns>', 'openbao: X-Vault-Namespace header value')
|
||||
.option('--mount <mount>', 'openbao: KV v2 mount point (default: secret)')
|
||||
.option('--path-prefix <prefix>', 'openbao: path prefix under mount (default: mcpctl)')
|
||||
.option('--token-secret <ref>', 'openbao: token secret reference in SECRET/KEY form (e.g. bao-creds/token)')
|
||||
.option('--auth <method>', "openbao: auth method — 'token' (default) or 'kubernetes'")
|
||||
.option('--token-secret <ref>', 'openbao token auth: token secret reference in SECRET/KEY form (e.g. bao-creds/token)')
|
||||
.option('--role <name>', "openbao kubernetes auth: vault role to login as (e.g. 'mcpctl')")
|
||||
.option('--auth-mount <path>', "openbao kubernetes auth: vault auth method mount path (default: 'kubernetes')")
|
||||
.option('--sa-token-path <path>', "openbao kubernetes auth: filesystem path to projected SA token (default: '/var/run/secrets/kubernetes.io/serviceaccount/token')")
|
||||
.option('--config <entry>', 'Extra config as key=value (repeat for multiple)', collect, [])
|
||||
.option('--force', 'Update if already exists')
|
||||
.action(async (name: string, opts) => {
|
||||
@@ -328,14 +332,28 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
|
||||
|
||||
if (type === 'openbao') {
|
||||
if (!opts.url) throw new Error('--url is required for openbao backend');
|
||||
if (!opts.tokenSecret) throw new Error('--token-secret is required for openbao backend (format: SECRET/KEY)');
|
||||
const slashIdx = (opts.tokenSecret as string).indexOf('/');
|
||||
if (slashIdx < 1) throw new Error(`Invalid --token-secret '${opts.tokenSecret as string}'. Expected SECRET_NAME/KEY_NAME`);
|
||||
const auth = (opts.auth as string | undefined) ?? 'token';
|
||||
if (auth !== 'token' && auth !== 'kubernetes') {
|
||||
throw new Error(`--auth must be 'token' or 'kubernetes' (got '${auth}')`);
|
||||
}
|
||||
config.url = opts.url;
|
||||
config.tokenSecretRef = {
|
||||
name: (opts.tokenSecret as string).slice(0, slashIdx),
|
||||
key: (opts.tokenSecret as string).slice(slashIdx + 1),
|
||||
};
|
||||
config.auth = auth;
|
||||
|
||||
if (auth === 'token') {
|
||||
if (!opts.tokenSecret) throw new Error('--token-secret is required for openbao token auth (format: SECRET/KEY)');
|
||||
const slashIdx = (opts.tokenSecret as string).indexOf('/');
|
||||
if (slashIdx < 1) throw new Error(`Invalid --token-secret '${opts.tokenSecret as string}'. Expected SECRET_NAME/KEY_NAME`);
|
||||
config.tokenSecretRef = {
|
||||
name: (opts.tokenSecret as string).slice(0, slashIdx),
|
||||
key: (opts.tokenSecret as string).slice(slashIdx + 1),
|
||||
};
|
||||
} else {
|
||||
if (!opts.role) throw new Error("--role is required for openbao kubernetes auth (the vault role bound to this pod's ServiceAccount)");
|
||||
config.role = opts.role;
|
||||
if (opts.authMount) config.authMount = opts.authMount;
|
||||
if (opts.saTokenPath) config.serviceAccountTokenPath = opts.saTokenPath;
|
||||
}
|
||||
|
||||
if (opts.namespace) config.namespace = opts.namespace;
|
||||
if (opts.mount) config.mount = opts.mount;
|
||||
if (opts.pathPrefix) config.pathPrefix = opts.pathPrefix;
|
||||
|
||||
@@ -25,10 +25,26 @@ export function createDriver(row: SecretBackend, deps: DriverFactoryDeps): Secre
|
||||
|
||||
case 'openbao': {
|
||||
const cfg = row.config as unknown as OpenBaoConfig;
|
||||
if (!cfg.url || !cfg.tokenSecretRef?.name || !cfg.tokenSecretRef?.key) {
|
||||
throw new Error(
|
||||
`SecretBackend '${row.name}' (openbao): config must provide url + tokenSecretRef {name, key}`,
|
||||
);
|
||||
if (!cfg.url) {
|
||||
throw new Error(`SecretBackend '${row.name}' (openbao): config.url is required`);
|
||||
}
|
||||
const auth = cfg.auth ?? 'token';
|
||||
if (auth === 'token') {
|
||||
const t = cfg as Extract<OpenBaoConfig, { auth?: 'token' }>;
|
||||
if (!t.tokenSecretRef?.name || !t.tokenSecretRef?.key) {
|
||||
throw new Error(
|
||||
`SecretBackend '${row.name}' (openbao token auth): config.tokenSecretRef {name, key} is required`,
|
||||
);
|
||||
}
|
||||
} else if (auth === 'kubernetes') {
|
||||
const k = cfg as Extract<OpenBaoConfig, { auth: 'kubernetes' }>;
|
||||
if (!k.role) {
|
||||
throw new Error(
|
||||
`SecretBackend '${row.name}' (openbao kubernetes auth): config.role is required`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
throw new Error(`SecretBackend '${row.name}' (openbao): unknown auth '${String(auth)}'`);
|
||||
}
|
||||
const driverDeps: { fetch?: typeof globalThis.fetch; secretRefResolver: SecretRefResolver } = {
|
||||
secretRefResolver: deps.secretRefResolver,
|
||||
|
||||
@@ -8,33 +8,69 @@
|
||||
* GET <url>/v1/<mount>/data/<path> -- read latest
|
||||
* DELETE <url>/v1/<mount>/metadata/<path> -- full delete (all versions)
|
||||
* LIST <url>/v1/<mount>/metadata/ -- for migration
|
||||
* POST <url>/v1/auth/<mount>/login -- kubernetes auth
|
||||
*
|
||||
* Auth: static token for v1. The token is stored in a `Secret` on the
|
||||
* plaintext backend (see `config.tokenSecretRef = { name, key }`); the driver
|
||||
* resolves it on construction via the injected `SecretRefResolver`. Follow-up
|
||||
* work (not here) adds Kubernetes ServiceAccount auth.
|
||||
* Auth strategies (`config.auth`):
|
||||
* - `token` (default): static token loaded once via the injected
|
||||
* SecretRefResolver from a Secret on the plaintext backend
|
||||
* (`tokenSecretRef = { name, key }`). Cached for the driver's lifetime —
|
||||
* no expiry handling.
|
||||
* - `kubernetes`: log in to OpenBao's Kubernetes auth method using the
|
||||
* pod's projected ServiceAccount token. Vault returns a client token +
|
||||
* lease TTL; we cache it and renew lazily on TTL expiry, with a 60s
|
||||
* grace window. No static credentials in the database — the bao-side
|
||||
* role binds to the mcpd ServiceAccount + namespace.
|
||||
*
|
||||
* Path layout inside OpenBao:
|
||||
* <mount>/<pathPrefix>/<secretName>
|
||||
* `mount` and `pathPrefix` come from the backend's `config` JSON; defaults are
|
||||
* `secret` and `mcpctl/`.
|
||||
*/
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import type { SecretBackendDriver, SecretData, ExternalRef, SecretRefResolver } from './types.js';
|
||||
|
||||
export interface OpenBaoConfig {
|
||||
export interface OpenBaoConfigBase {
|
||||
url: string;
|
||||
mount?: string;
|
||||
pathPrefix?: string;
|
||||
namespace?: string;
|
||||
}
|
||||
|
||||
export interface OpenBaoConfigToken extends OpenBaoConfigBase {
|
||||
auth?: 'token';
|
||||
tokenSecretRef: { name: string; key: string };
|
||||
}
|
||||
|
||||
export interface OpenBaoConfigKubernetes extends OpenBaoConfigBase {
|
||||
auth: 'kubernetes';
|
||||
/** Vault role to login as (configured server-side at `auth/<authMount>/role/<role>`). */
|
||||
role: string;
|
||||
/** Auth method mount path. Defaults to `kubernetes`. */
|
||||
authMount?: string;
|
||||
/**
|
||||
* Filesystem path to the projected ServiceAccount token. Defaults to
|
||||
* `/var/run/secrets/kubernetes.io/serviceaccount/token` (the standard
|
||||
* mount). Override only for tests or non-default projections.
|
||||
*/
|
||||
serviceAccountTokenPath?: string;
|
||||
}
|
||||
|
||||
export type OpenBaoConfig = OpenBaoConfigToken | OpenBaoConfigKubernetes;
|
||||
|
||||
export interface OpenBaoDriverDeps {
|
||||
/** Injected HTTP fetcher — mockable in tests. */
|
||||
fetch?: typeof globalThis.fetch;
|
||||
secretRefResolver: SecretRefResolver;
|
||||
/** Required only for `auth: 'token'`. */
|
||||
secretRefResolver?: SecretRefResolver;
|
||||
/** Override for the SA-token reader; tests use this to supply a fake JWT. */
|
||||
readServiceAccountToken?: (path: string) => Promise<string>;
|
||||
/** Clock for cache TTL — overridable in tests. */
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
const SA_TOKEN_DEFAULT_PATH = '/var/run/secrets/kubernetes.io/serviceaccount/token';
|
||||
const TOKEN_RENEW_GRACE_MS = 60_000;
|
||||
|
||||
export class OpenBaoDriver implements SecretBackendDriver {
|
||||
readonly kind = 'openbao';
|
||||
|
||||
@@ -42,19 +78,48 @@ export class OpenBaoDriver implements SecretBackendDriver {
|
||||
private readonly mount: string;
|
||||
private readonly pathPrefix: string;
|
||||
private readonly namespace: string | undefined;
|
||||
private readonly tokenSecretRef: { name: string; key: string };
|
||||
private readonly authStrategy: 'token' | 'kubernetes';
|
||||
private readonly tokenSecretRef: { name: string; key: string } | undefined;
|
||||
private readonly k8sRole: string | undefined;
|
||||
private readonly k8sAuthMount: string;
|
||||
private readonly k8sTokenPath: string;
|
||||
private readonly fetchImpl: typeof globalThis.fetch;
|
||||
private readonly resolver: SecretRefResolver;
|
||||
private readonly resolver: SecretRefResolver | undefined;
|
||||
private readonly readSaToken: (path: string) => Promise<string>;
|
||||
private readonly nowFn: () => number;
|
||||
|
||||
// Cached vault token + when (epoch ms) it should be considered expired and refetched.
|
||||
private cachedToken: string | undefined;
|
||||
private cachedTokenExpiresAt: number = Number.POSITIVE_INFINITY;
|
||||
|
||||
constructor(config: OpenBaoConfig, deps: OpenBaoDriverDeps) {
|
||||
this.url = config.url.replace(/\/+$/, '');
|
||||
this.mount = (config.mount ?? 'secret').replace(/^\/|\/$/g, '');
|
||||
this.pathPrefix = (config.pathPrefix ?? 'mcpctl').replace(/^\/|\/$/g, '');
|
||||
if (config.namespace !== undefined) this.namespace = config.namespace;
|
||||
this.tokenSecretRef = config.tokenSecretRef;
|
||||
|
||||
this.authStrategy = config.auth ?? 'token';
|
||||
if (this.authStrategy === 'kubernetes') {
|
||||
const k = config as OpenBaoConfigKubernetes;
|
||||
if (!k.role) throw new Error('openbao kubernetes auth: `role` is required');
|
||||
this.k8sRole = k.role;
|
||||
this.k8sAuthMount = (k.authMount ?? 'kubernetes').replace(/^\/|\/$/g, '');
|
||||
this.k8sTokenPath = k.serviceAccountTokenPath ?? SA_TOKEN_DEFAULT_PATH;
|
||||
} else {
|
||||
const t = config as OpenBaoConfigToken;
|
||||
if (!t.tokenSecretRef) throw new Error('openbao token auth: `tokenSecretRef` is required');
|
||||
if (deps.secretRefResolver === undefined) {
|
||||
throw new Error('openbao token auth: secretRefResolver dependency is required');
|
||||
}
|
||||
this.tokenSecretRef = t.tokenSecretRef;
|
||||
this.k8sAuthMount = 'kubernetes';
|
||||
this.k8sTokenPath = SA_TOKEN_DEFAULT_PATH;
|
||||
}
|
||||
|
||||
this.fetchImpl = deps.fetch ?? globalThis.fetch;
|
||||
this.resolver = deps.secretRefResolver;
|
||||
if (deps.secretRefResolver !== undefined) this.resolver = deps.secretRefResolver;
|
||||
this.readSaToken = deps.readServiceAccountToken ?? ((path) => readFile(path, 'utf-8').then((s) => s.trim()));
|
||||
this.nowFn = deps.now ?? (() => Date.now());
|
||||
}
|
||||
|
||||
async read(input: { name: string; externalRef: ExternalRef; data: SecretData }): Promise<SecretData> {
|
||||
@@ -113,10 +178,44 @@ export class OpenBaoDriver implements SecretBackendDriver {
|
||||
}
|
||||
|
||||
private async getToken(): Promise<string> {
|
||||
if (this.cachedToken !== undefined) return this.cachedToken;
|
||||
const token = await this.resolver.resolve(this.tokenSecretRef.name, this.tokenSecretRef.key);
|
||||
this.cachedToken = token;
|
||||
return token;
|
||||
if (this.cachedToken !== undefined && this.nowFn() < this.cachedTokenExpiresAt - TOKEN_RENEW_GRACE_MS) {
|
||||
return this.cachedToken;
|
||||
}
|
||||
|
||||
if (this.authStrategy === 'token') {
|
||||
// Static token from a plaintext Secret. No TTL — cache for the driver's lifetime.
|
||||
const token = await this.resolver!.resolve(this.tokenSecretRef!.name, this.tokenSecretRef!.key);
|
||||
this.cachedToken = token;
|
||||
this.cachedTokenExpiresAt = Number.POSITIVE_INFINITY;
|
||||
return token;
|
||||
}
|
||||
|
||||
// Kubernetes auth: read the projected SA JWT, exchange it for a Vault token.
|
||||
const jwt = await this.readSaToken(this.k8sTokenPath);
|
||||
const loginUrl = `${this.url}/v1/auth/${this.k8sAuthMount}/login`;
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (this.namespace !== undefined) headers['X-Vault-Namespace'] = this.namespace;
|
||||
const res = await this.fetchImpl(loginUrl, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ role: this.k8sRole, jwt }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(`OpenBao kubernetes login (role=${this.k8sRole!}): HTTP ${String(res.status)} ${text}`);
|
||||
}
|
||||
const body = await res.json() as { auth?: { client_token?: string; lease_duration?: number } };
|
||||
const clientToken = body.auth?.client_token;
|
||||
if (clientToken === undefined || clientToken === '') {
|
||||
throw new Error(`OpenBao kubernetes login: response missing auth.client_token`);
|
||||
}
|
||||
// lease_duration is seconds; 0 means token doesn't expire (rare for k8s auth).
|
||||
const leaseSec = body.auth?.lease_duration ?? 0;
|
||||
this.cachedToken = clientToken;
|
||||
this.cachedTokenExpiresAt = leaseSec > 0
|
||||
? this.nowFn() + leaseSec * 1000
|
||||
: Number.POSITIVE_INFINITY;
|
||||
return clientToken;
|
||||
}
|
||||
|
||||
private async request(method: string, path: string, body?: unknown): Promise<Response> {
|
||||
@@ -128,6 +227,21 @@ export class OpenBaoDriver implements SecretBackendDriver {
|
||||
const init: RequestInit = { method, headers };
|
||||
if (body !== undefined) init.body = JSON.stringify(body);
|
||||
|
||||
return this.fetchImpl(`${this.url}${path}`, init);
|
||||
const res = await this.fetchImpl(`${this.url}${path}`, init);
|
||||
|
||||
// If the cached token expired between cache-check and request (k8s clock
|
||||
// skew, server-side revocation, etc.), purge cache and retry once.
|
||||
if (res.status === 403 && this.cachedToken !== undefined) {
|
||||
this.cachedToken = undefined;
|
||||
this.cachedTokenExpiresAt = 0;
|
||||
const fresh = await this.getToken();
|
||||
const retryHeaders: Record<string, string> = { 'X-Vault-Token': fresh };
|
||||
if (this.namespace !== undefined) retryHeaders['X-Vault-Namespace'] = this.namespace;
|
||||
if (body !== undefined) retryHeaders['Content-Type'] = 'application/json';
|
||||
const retryInit: RequestInit = { method, headers: retryHeaders };
|
||||
if (body !== undefined) retryInit.body = JSON.stringify(body);
|
||||
return this.fetchImpl(`${this.url}${path}`, retryInit);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,4 +129,116 @@ describe('OpenBaoDriver', () => {
|
||||
const headers = init.headers as Record<string, string>;
|
||||
expect(headers['X-Vault-Namespace']).toBe('myteam');
|
||||
});
|
||||
|
||||
describe('kubernetes auth', () => {
|
||||
it('exchanges the SA JWT for a vault client token via /v1/auth/kubernetes/login', async () => {
|
||||
const calls: Array<{ url: string; init: RequestInit }> = [];
|
||||
const fetchFn = vi.fn(async (url: string | URL, init?: RequestInit) => {
|
||||
const u = String(url);
|
||||
calls.push({ url: u, init: init ?? {} });
|
||||
if (u.endsWith('/v1/auth/kubernetes/login')) {
|
||||
return new Response(JSON.stringify({
|
||||
auth: { client_token: 'vault.client.token.xyz', lease_duration: 3600 },
|
||||
}), { status: 200 });
|
||||
}
|
||||
return new Response(JSON.stringify({}), { status: 200 });
|
||||
});
|
||||
|
||||
const driver = new OpenBaoDriver(
|
||||
{ url: 'http://bao.example:8200', auth: 'kubernetes', role: 'mcpctl' },
|
||||
{
|
||||
fetch: fetchFn as unknown as typeof fetch,
|
||||
readServiceAccountToken: async () => 'eyJ.fake.sa.jwt',
|
||||
},
|
||||
);
|
||||
await driver.write({ name: 'x', data: { k: 'v' } });
|
||||
|
||||
// Two calls: login + write
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(calls[0]!.url).toBe('http://bao.example:8200/v1/auth/kubernetes/login');
|
||||
expect(JSON.parse(calls[0]!.init.body as string)).toEqual({ role: 'mcpctl', jwt: 'eyJ.fake.sa.jwt' });
|
||||
|
||||
// Write uses the returned client token
|
||||
const writeHeaders = calls[1]!.init.headers as Record<string, string>;
|
||||
expect(writeHeaders['X-Vault-Token']).toBe('vault.client.token.xyz');
|
||||
});
|
||||
|
||||
it('caches the vault token across requests and renews after lease expiry', async () => {
|
||||
let nowMs = 1_000_000_000_000;
|
||||
let loginCount = 0;
|
||||
const fetchFn = vi.fn(async (url: string | URL) => {
|
||||
const u = String(url);
|
||||
if (u.endsWith('/v1/auth/kubernetes/login')) {
|
||||
loginCount++;
|
||||
// 600s lease leaves 540s of cached window after the 60s grace.
|
||||
return new Response(JSON.stringify({
|
||||
auth: { client_token: `tok-${String(loginCount)}`, lease_duration: 600 },
|
||||
}), { status: 200 });
|
||||
}
|
||||
return new Response(JSON.stringify({}), { status: 200 });
|
||||
});
|
||||
|
||||
const driver = new OpenBaoDriver(
|
||||
{ url: 'http://bao.example:8200', auth: 'kubernetes', role: 'mcpctl' },
|
||||
{
|
||||
fetch: fetchFn as unknown as typeof fetch,
|
||||
readServiceAccountToken: async () => 'jwt',
|
||||
now: () => nowMs,
|
||||
},
|
||||
);
|
||||
|
||||
await driver.write({ name: 'a', data: { k: 'v' } });
|
||||
await driver.write({ name: 'b', data: { k: 'v' } });
|
||||
expect(loginCount).toBe(1); // both writes share the cached token
|
||||
|
||||
// Advance past lease - grace window → driver re-logs in
|
||||
nowMs += 600_000;
|
||||
await driver.write({ name: 'c', data: { k: 'v' } });
|
||||
expect(loginCount).toBe(2);
|
||||
});
|
||||
|
||||
it('honours custom authMount path', async () => {
|
||||
const calls: string[] = [];
|
||||
const fetchFn = vi.fn(async (url: string | URL) => {
|
||||
calls.push(String(url));
|
||||
if (String(url).includes('/login')) {
|
||||
return new Response(JSON.stringify({ auth: { client_token: 't', lease_duration: 3600 } }), { status: 200 });
|
||||
}
|
||||
return new Response(JSON.stringify({}), { status: 200 });
|
||||
});
|
||||
|
||||
const driver = new OpenBaoDriver(
|
||||
{ url: 'http://bao.example:8200', auth: 'kubernetes', role: 'mcpctl', authMount: 'kubernetes/cluster-a' },
|
||||
{ fetch: fetchFn as unknown as typeof fetch, readServiceAccountToken: async () => 'jwt' },
|
||||
);
|
||||
await driver.write({ name: 'x', data: {} });
|
||||
expect(calls[0]).toBe('http://bao.example:8200/v1/auth/kubernetes/cluster-a/login');
|
||||
});
|
||||
|
||||
it('throws on login failure with a clear error', async () => {
|
||||
const fetchFn = vi.fn(async () => new Response('permission denied', { status: 403 }));
|
||||
const driver = new OpenBaoDriver(
|
||||
{ url: 'http://bao.example:8200', auth: 'kubernetes', role: 'mcpctl' },
|
||||
{ fetch: fetchFn as unknown as typeof fetch, readServiceAccountToken: async () => 'jwt' },
|
||||
);
|
||||
await expect(driver.read({ name: 'x', externalRef: '', data: {} }))
|
||||
.rejects.toThrow(/kubernetes login.*role=mcpctl.*HTTP 403/);
|
||||
});
|
||||
|
||||
it('rejects construction when role is missing', () => {
|
||||
expect(() => new OpenBaoDriver(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
{ url: 'http://bao.example:8200', auth: 'kubernetes' } as any,
|
||||
{ fetch: vi.fn() as unknown as typeof fetch, readServiceAccountToken: async () => 'jwt' },
|
||||
)).toThrow(/role.*required/);
|
||||
});
|
||||
|
||||
it('rejects token-auth construction when tokenSecretRef is missing', () => {
|
||||
expect(() => new OpenBaoDriver(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
{ url: 'http://bao.example:8200' } as any,
|
||||
{ fetch: vi.fn() as unknown as typeof fetch, secretRefResolver: resolver },
|
||||
)).toThrow(/tokenSecretRef.*required/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user