From 0556bcd0f232a3b70a5e95a6f26228e1e3507b91 Mon Sep 17 00:00:00 2001 From: Kody <72270156+kody-bot@users.noreply.github.com> Date: Tue, 6 Oct 2026 07:42:36 -0600 Subject: [PATCH] feat(auth): require lifetime on auth bootstrap Require --lifetime short|long or both idle/max TTL flags on auth bootstrap, and pass the alias or explicit seconds through to bootstrap redeem (ADR 0056). --- src/api-token.ts | 2 +- src/auth-bootstrap.ts | 189 +++++++++++++++++++++++++++++++++++- src/cli.ts | 51 +++++++++- src/help.ts | 6 +- test/auth-bootstrap.test.ts | 108 ++++++++++++++++++++- 5 files changed, 344 insertions(+), 12 deletions(-) diff --git a/src/api-token.ts b/src/api-token.ts index 234a388..d7f91b8 100644 --- a/src/api-token.ts +++ b/src/api-token.ts @@ -121,7 +121,7 @@ export function apiTokenMintInstructions(): string { /** Preferred interactive path for agents already on Kody MCP (ADR 0056). */ export function cliBootstrapInstructions(): string { - return `From MCP, call \`cliCredentialBootstrap\` (MCP \`api\` / \`kody.cliCredentialBootstrap\`), then run \`npx @kodycodes/cli auth bootstrap --code \`` + return `From MCP, call \`cliCredentialBootstrap\` (MCP \`api\` / \`kody.cliCredentialBootstrap\`), then run \`npx @kodycodes/cli auth bootstrap --code --lifetime short\`` } /** Token-only Open API paths (search / whoami / cloud token execute) with no token. */ diff --git a/src/auth-bootstrap.ts b/src/auth-bootstrap.ts index 3a34827..ee5055c 100644 --- a/src/auth-bootstrap.ts +++ b/src/auth-bootstrap.ts @@ -16,6 +16,50 @@ const bootstrapCodePattern = /^kody_bc_([a-z0-9]{16})_([A-Za-z0-9_-]{32})$/ export const bootstrapRedeemPath = 'v1/tokens/bootstrap/redeem' +/** ADR 0056 idle / absolute lifetime caps (seconds). */ +export const cliTokenLifetimePolicy = { + minIdleTtlSeconds: 60, + /** Idle timeout at most 14 days. */ + maxIdleTtlSeconds: 14 * 24 * 60 * 60, + /** Absolute lifetime at most 3 months. */ + maxMaxLifetimeSeconds: 90 * 24 * 60 * 60, +} as const + +/** + * Input aliases for required token lifetimes. Sugar only: never stored on the + * token. `short` suits single-task agents; `long` is the policy maximum. + */ +export const cliTokenLifetimeAliases = { + short: { + idleTtlSeconds: 60 * 60, + maxLifetimeSeconds: 24 * 60 * 60, + }, + long: { + idleTtlSeconds: cliTokenLifetimePolicy.maxIdleTtlSeconds, + maxLifetimeSeconds: cliTokenLifetimePolicy.maxMaxLifetimeSeconds, + }, +} as const + +export type CliTokenLifetimeAlias = keyof typeof cliTokenLifetimeAliases + +/** + * Resolved lifetime for redeem. Alias form keeps the label only for the + * redeem JSON body (`lifetime`); explicit form sends idle/max seconds. + * Neither label nor alias is persisted with the stored API token. + */ +export type ResolvedCliTokenLifetime = + | { + kind: 'alias' + lifetime: CliTokenLifetimeAlias + idleTtlSeconds: number + maxLifetimeSeconds: number + } + | { + kind: 'explicit' + idleTtlSeconds: number + maxLifetimeSeconds: number + } + export type BootstrapRedeemResponse = { token: string token_type?: string @@ -29,6 +73,14 @@ export type BootstrapRedeemResponse = { created_via?: string } +export type BootstrapRedeemRequestBody = + | { code: string; lifetime: CliTokenLifetimeAlias } + | { + code: string + idle_ttl_seconds: number + max_lifetime_seconds: number + } + export function parseCliBootstrapCode(value: string): { codeId: string; secret: string } | null { const match = bootstrapCodePattern.exec(value.trim()) if (!match) return null @@ -47,16 +99,125 @@ export function assertCliBootstrapCode(code: string): string { return trimmed } +/** Exact CLI flag syntax for a missing lifetime (ADR 0056). */ +export function cliTokenLifetimeMissingError(): string { + return ( + 'Token lifetime is required. Pass --lifetime short|long, or both ' + + `--idle-ttl-seconds and --max-lifetime-seconds ` + + `(idle ${cliTokenLifetimePolicy.minIdleTtlSeconds}-${cliTokenLifetimePolicy.maxIdleTtlSeconds}s, ` + + `max age up to ${cliTokenLifetimePolicy.maxMaxLifetimeSeconds}s). ` + + 'Single-task agents should use --lifetime short.' + ) +} + /** - * POST /v1/tokens/bootstrap/redeem with JSON `{ code }` and **no** Authorization + * Resolve a required lifetime choice. Aliases expand to idle/max seconds for + * validation; the redeem body still sends the alias or explicit seconds only. + */ +export function resolveCliTokenLifetime(input: { + lifetime?: string | null + idleTtlSeconds?: number + maxLifetimeSeconds?: number +}): ResolvedCliTokenLifetime { + const aliasRaw = typeof input.lifetime === 'string' ? input.lifetime.trim() : '' + const hasAlias = aliasRaw.length > 0 + const hasIdle = input.idleTtlSeconds !== undefined + const hasMax = input.maxLifetimeSeconds !== undefined + + if (!hasAlias && !hasIdle && !hasMax) { + throw new Error(cliTokenLifetimeMissingError()) + } + if (hasAlias && (hasIdle || hasMax)) { + throw new Error( + 'Pass --lifetime short|long, or both --idle-ttl-seconds and --max-lifetime-seconds, not both forms.', + ) + } + if (hasAlias) { + if (!(aliasRaw in cliTokenLifetimeAliases)) { + throw new Error( + `--lifetime must be short or long (got ${JSON.stringify(aliasRaw)}).`, + ) + } + const lifetime = aliasRaw as CliTokenLifetimeAlias + const resolved = cliTokenLifetimeAliases[lifetime] + return { + kind: 'alias', + lifetime, + idleTtlSeconds: resolved.idleTtlSeconds, + maxLifetimeSeconds: resolved.maxLifetimeSeconds, + } + } + if (!hasIdle || !hasMax) { + throw new Error( + 'When not using --lifetime short|long, both --idle-ttl-seconds and --max-lifetime-seconds are required.', + ) + } + const idleTtlSeconds = readRequiredInteger({ + value: input.idleTtlSeconds!, + min: cliTokenLifetimePolicy.minIdleTtlSeconds, + max: cliTokenLifetimePolicy.maxIdleTtlSeconds, + field: '--idle-ttl-seconds', + }) + const maxLifetimeSeconds = readRequiredInteger({ + value: input.maxLifetimeSeconds!, + min: idleTtlSeconds, + max: cliTokenLifetimePolicy.maxMaxLifetimeSeconds, + field: '--max-lifetime-seconds', + }) + return { + kind: 'explicit', + idleTtlSeconds, + maxLifetimeSeconds, + } +} + +/** Build the redeem JSON body (alias or explicit seconds — never both). */ +export function bootstrapRedeemRequestBody( + code: string, + lifetime: ResolvedCliTokenLifetime, +): BootstrapRedeemRequestBody { + if (lifetime.kind === 'alias') { + return { code, lifetime: lifetime.lifetime } + } + return { + code, + idle_ttl_seconds: lifetime.idleTtlSeconds, + max_lifetime_seconds: lifetime.maxLifetimeSeconds, + } +} + +/** Parse a CLI `--idle-ttl-seconds` / `--max-lifetime-seconds` string flag. */ +export function parseCliLifetimeSecondsFlag( + raw: string | undefined, + flag: '--idle-ttl-seconds' | '--max-lifetime-seconds', +): number | undefined { + if (raw === undefined) return undefined + const trimmed = raw.trim() + if (!/^-?\d+$/.test(trimmed)) { + throw new Error(`${flag} must be an integer.`) + } + return Number(trimmed) +} + +/** + * POST /v1/tokens/bootstrap/redeem with JSON `{ code, lifetime }` or + * `{ code, idle_ttl_seconds, max_lifetime_seconds }` and **no** Authorization * header (ADR 0056). Returns the minted `kody_at_…` once. */ export async function redeemBootstrapCode(input: { code: string + lifetime?: string | null + idleTtlSeconds?: number + maxLifetimeSeconds?: number apiUrl?: string fetchFn?: typeof fetch }): Promise { const code = assertCliBootstrapCode(input.code) + const lifetime = resolveCliTokenLifetime({ + lifetime: input.lifetime, + idleTtlSeconds: input.idleTtlSeconds, + maxLifetimeSeconds: input.maxLifetimeSeconds, + }) const apiUrl = input.apiUrl || defaultApiUrl assertTokenSafeApiUrl(apiUrl) const url = capabilityProxyUrl(apiUrl, bootstrapRedeemPath) @@ -70,7 +231,7 @@ export async function redeemBootstrapCode(input: { 'content-type': 'application/json', 'user-agent': `${cliName}/${readPackageVersion()}`, }, - body: JSON.stringify({ code }), + body: JSON.stringify(bootstrapRedeemRequestBody(code, lifetime)), }) } catch (error) { const reason = describeNetworkError(error) @@ -113,6 +274,9 @@ export function storedApiTokenFromRedeem(input: { */ export async function authBootstrap(input: { code: string + lifetime?: string | null + idleTtlSeconds?: number + maxLifetimeSeconds?: number apiUrl?: string fetchFn?: typeof fetch backend?: SecretBackend @@ -125,6 +289,9 @@ export async function authBootstrap(input: { const apiUrl = input.apiUrl || defaultApiUrl const redeemed = await redeemBootstrapCode({ code: input.code, + lifetime: input.lifetime, + idleTtlSeconds: input.idleTtlSeconds, + maxLifetimeSeconds: input.maxLifetimeSeconds, apiUrl, fetchFn: input.fetchFn, }) @@ -137,6 +304,24 @@ export async function authBootstrap(input: { } } +function readRequiredInteger(input: { + value: number + min: number + max: number + field: string +}): number { + if ( + !Number.isInteger(input.value) || + input.value < input.min || + input.value > input.max + ) { + throw new Error( + `${input.field} must be an integer between ${input.min} and ${input.max}.`, + ) + } + return input.value +} + function parseRedeemResponse(body: unknown): BootstrapRedeemResponse { if (!isRecord(body) || typeof body.token !== 'string' || typeof body.id !== 'string') { throw new Error('Bootstrap redeem returned an unexpected response.') diff --git a/src/cli.ts b/src/cli.ts index 1b4f16f..471c4f2 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -9,7 +9,11 @@ import { deleteStoredApiToken, loadStoredApiToken, } from './api-token-store.js' -import { authBootstrap } from './auth-bootstrap.js' +import { + authBootstrap, + parseCliLifetimeSecondsFlag, + resolveCliTokenLifetime, +} from './auth-bootstrap.js' import { defaultApiUrl, defaultMcpUrl, modernMcpProtocolVersion } from './defaults.js' import { usage } from './help.js' import { ensureFreshCredentials, login } from './auth.js' @@ -35,7 +39,16 @@ export { resolveScopedApiToken, } from './api-token.js' export { resolveLocalExecuteBearer } from './local-execute-auth.js' -export { authBootstrap, redeemBootstrapCode } from './auth-bootstrap.js' +export { + authBootstrap, + bootstrapRedeemRequestBody, + cliTokenLifetimeAliases, + cliTokenLifetimeMissingError, + cliTokenLifetimePolicy, + parseCliLifetimeSecondsFlag, + redeemBootstrapCode, + resolveCliTokenLifetime, +} from './auth-bootstrap.js' export type CommandName = | 'login' @@ -84,6 +97,9 @@ function parseKnown(args: Array) { 'allow-private-network': { type: 'boolean' }, token: { type: 'string' }, 'api-url': { type: 'string' }, + lifetime: { type: 'string' }, + 'idle-ttl-seconds': { type: 'string' }, + 'max-lifetime-seconds': { type: 'string' }, project: { type: 'boolean' }, 'no-browser': { type: 'boolean' }, clients: { type: 'string' }, @@ -253,7 +269,7 @@ async function dispatch( const action = parsed.positionals[0] if (action !== 'bootstrap') { throw new Error( - 'Usage: kody auth bootstrap --code [--api-url ]', + 'Usage: kody auth bootstrap --code (--lifetime short|long | --idle-ttl-seconds --max-lifetime-seconds ) [--api-url ]', ) } const code = @@ -263,13 +279,40 @@ async function dispatch( 'Provide --code from cliCredentialBootstrap (MCP api / kody.cliCredentialBootstrap).', ) } + const lifetime = resolveCliTokenLifetime({ + lifetime: + typeof parsed.values.lifetime === 'string' + ? parsed.values.lifetime + : undefined, + idleTtlSeconds: parseCliLifetimeSecondsFlag( + typeof parsed.values['idle-ttl-seconds'] === 'string' + ? parsed.values['idle-ttl-seconds'] + : undefined, + '--idle-ttl-seconds', + ), + maxLifetimeSeconds: parseCliLifetimeSecondsFlag( + typeof parsed.values['max-lifetime-seconds'] === 'string' + ? parsed.values['max-lifetime-seconds'] + : undefined, + '--max-lifetime-seconds', + ), + }) const apiUrl = apiUrlFrom({ apiUrl: typeof parsed.values['api-url'] === 'string' ? parsed.values['api-url'] : undefined, }) - const result = await authBootstrap({ code, apiUrl }) + const result = await authBootstrap({ + code, + apiUrl, + ...(lifetime.kind === 'alias' + ? { lifetime: lifetime.lifetime } + : { + idleTtlSeconds: lifetime.idleTtlSeconds, + maxLifetimeSeconds: lifetime.maxLifetimeSeconds, + }), + }) const scopes = result.stored.scopes?.join(', ') || '(none)' write( [ diff --git a/src/help.ts b/src/help.ts index 58c5fcc..cd2d624 100644 --- a/src/help.ts +++ b/src/help.ts @@ -10,7 +10,7 @@ Usage: kody login [--mcp-url ] [--no-browser] kody logout [--mcp-url ] [--api-url ] kody status [--mcp-url ] [--api-url ] - kody auth bootstrap --code [--api-url ] + kody auth bootstrap --code (--lifetime short|long | --idle-ttl-seconds --max-lifetime-seconds ) [--api-url ] kody whoami [--mcp-url ] [--token ] [--api-url ] [--json] kody search [query] [--entity ] [--domain ] [--limit ] [--token ] [--api-url ] [--json] kody api [--params ] [--token ] [--api-url ] [--json] @@ -28,6 +28,10 @@ Usage: auth bootstrap Redeem a one-shot \`kody_bc_…\` from MCP \`cliCredentialBootstrap\` (POST /v1/tokens/bootstrap/redeem, no Authorization header). + Lifetime is required: \`--lifetime short|long\` (\`short\` = 1h + idle / 24h max; \`long\` = 14d idle / 3mo max), or both + \`--idle-ttl-seconds\` and \`--max-lifetime-seconds\`. + Single-task agents should use \`--lifetime short\`. Stores the resulting \`kody_at_…\` for \`execute --local\`, search, whoami, api, and token-auth cloud execute without printing the token. Prefer this over tokenCreate for agents diff --git a/test/auth-bootstrap.test.ts b/test/auth-bootstrap.test.ts index d06cc29..2e3fa5e 100644 --- a/test/auth-bootstrap.test.ts +++ b/test/auth-bootstrap.test.ts @@ -8,8 +8,11 @@ import { after, before, test } from 'node:test' import { assertCliBootstrapCode, authBootstrap, + cliTokenLifetimeAliases, + cliTokenLifetimeMissingError, parseCliBootstrapCode, redeemBootstrapCode, + resolveCliTokenLifetime, } from '../src/auth-bootstrap.js' import { loadStoredApiToken, @@ -103,10 +106,43 @@ test('parseCliBootstrapCode accepts platform-shaped codes', () => { assert.throws(() => assertCliBootstrapCode('nope'), /cliCredentialBootstrap/) }) -test('redeemBootstrapCode POSTs JSON code with no Authorization header', async () => { +test('resolveCliTokenLifetime requires a choice and expands short/long/explicit', () => { + assert.throws(() => resolveCliTokenLifetime({}), /Token lifetime is required/) + assert.throws(() => resolveCliTokenLifetime({}), /--lifetime short\|long/) + assert.throws(() => resolveCliTokenLifetime({}), /Single-task agents should use --lifetime short/) + assert.equal(cliTokenLifetimeMissingError().includes('--lifetime short|long'), true) + assert.deepEqual(resolveCliTokenLifetime({ lifetime: 'short' }), { + kind: 'alias', + lifetime: 'short', + idleTtlSeconds: cliTokenLifetimeAliases.short.idleTtlSeconds, + maxLifetimeSeconds: cliTokenLifetimeAliases.short.maxLifetimeSeconds, + }) + assert.deepEqual(resolveCliTokenLifetime({ lifetime: 'long' }), { + kind: 'alias', + lifetime: 'long', + idleTtlSeconds: cliTokenLifetimeAliases.long.idleTtlSeconds, + maxLifetimeSeconds: cliTokenLifetimeAliases.long.maxLifetimeSeconds, + }) + assert.deepEqual( + resolveCliTokenLifetime({ idleTtlSeconds: 120, maxLifetimeSeconds: 600 }), + { kind: 'explicit', idleTtlSeconds: 120, maxLifetimeSeconds: 600 }, + ) + assert.throws( + () => + resolveCliTokenLifetime({ + lifetime: 'short', + idleTtlSeconds: 120, + maxLifetimeSeconds: 600, + }), + /not both forms/, + ) +}) + +test('redeemBootstrapCode POSTs JSON code with lifetime and no Authorization header', async () => { reset() const redeemed = await redeemBootstrapCode({ code: goodCode, + lifetime: 'short', apiUrl, fetchFn: fetch, }) @@ -114,17 +150,34 @@ test('redeemBootstrapCode POSTs JSON code with no Authorization header', async ( assert.equal(requests[0]?.method, 'POST') assert.equal(requests[0]?.url, '/v1/tokens/bootstrap/redeem') assert.equal(requests[0]?.authorization, null) - assert.deepEqual(requests[0]?.body, { code: goodCode }) + assert.deepEqual(requests[0]?.body, { code: goodCode, lifetime: 'short' }) assert.equal(redeemed.token, goodToken) assert.equal(redeemed.id, 'tok_bootstrap_1') assert.equal(redeemed.created_via, 'cli-bootstrap') }) +test('redeemBootstrapCode POSTs explicit idle/max seconds when aliases are omitted', async () => { + reset() + await redeemBootstrapCode({ + code: goodCode, + idleTtlSeconds: 180, + maxLifetimeSeconds: 900, + apiUrl, + fetchFn: fetch, + }) + assert.deepEqual(requests[0]?.body, { + code: goodCode, + idle_ttl_seconds: 180, + max_lifetime_seconds: 900, + }) +}) + test('authBootstrap redeems and stores without exposing the token in returned metadata fields used for printing', async () => { reset() const backend = tempBackend() const result = await authBootstrap({ code: goodCode, + lifetime: 'short', apiUrl, backend, fetchFn: fetch, @@ -133,6 +186,7 @@ test('authBootstrap redeems and stores without exposing the token in returned me assert.equal(result.stored.tokenId, 'tok_bootstrap_1') assert.equal(result.stored.createdVia, 'cli-bootstrap') assert.deepEqual(result.stored.scopes, ['account:read', 'local-execute']) + assert.deepEqual(requests[0]?.body, { code: goodCode, lifetime: 'short' }) const loaded = loadStoredApiToken(apiUrl, backend) assert.equal(loaded?.token, goodToken) assert.equal(loaded?.tokenId, 'tok_bootstrap_1') @@ -140,6 +194,7 @@ test('authBootstrap redeems and stores without exposing the token in returned me const onDisk = JSON.parse(readFileSync(backend.path, 'utf8')) as StoredApiToken assert.equal(onDisk.token, goodToken) assert.equal(onDisk.version, 1) + assert.equal('lifetime' in onDisk, false) }) test('runCli auth bootstrap redeems and never prints the kody_at_ token', async () => { @@ -152,7 +207,16 @@ test('runCli auth bootstrap redeems and never prints the kody_at_ token', async let stdout = '' try { const code = await runCli( - ['auth', 'bootstrap', '--code', goodCode, '--api-url', apiUrl], + [ + 'auth', + 'bootstrap', + '--code', + goodCode, + '--lifetime', + 'short', + '--api-url', + apiUrl, + ], { stdout: (text) => { stdout += text @@ -166,6 +230,7 @@ test('runCli auth bootstrap redeems and never prints the kody_at_ token', async assert.doesNotMatch(stdout, /kody_at_/) assert.doesNotMatch(stdout, new RegExp(goodToken.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))) assert.equal(requests[0]?.authorization, null) + assert.deepEqual(requests[0]?.body, { code: goodCode, lifetime: 'short' }) } finally { if (previousXdg === undefined) delete process.env.XDG_CONFIG_HOME else process.env.XDG_CONFIG_HOME = previousXdg @@ -174,6 +239,26 @@ test('runCli auth bootstrap redeems and never prints the kody_at_ token', async } }) +test('runCli auth bootstrap fails clearly when lifetime is missing', async () => { + reset() + let stderr = '' + const code = await runCli( + ['auth', 'bootstrap', '--code', goodCode, '--api-url', apiUrl], + { + stderr: (text) => { + stderr += text + }, + }, + ) + assert.equal(code, 1) + assert.match(stderr, /Token lifetime is required/) + assert.match(stderr, /--lifetime short\|long/) + assert.match(stderr, /--idle-ttl-seconds/) + assert.match(stderr, /--max-lifetime-seconds/) + assert.match(stderr, /Single-task agents should use --lifetime short/) + assert.equal(requests.length, 0) +}) + test('resolveLocalExecuteBearer prefers stored bootstrap token over login OAuth', async () => { const backend = tempBackend() saveStoredApiToken( @@ -389,7 +474,22 @@ test('redeemBootstrapCode surfaces already-used codes clearly', async () => { redeemStatus = 400 redeemBody = { error: { code: 'invalid_request', message: 'already redeemed' } } await assert.rejects( - () => redeemBootstrapCode({ code: goodCode, apiUrl, fetchFn: fetch }), + () => + redeemBootstrapCode({ + code: goodCode, + lifetime: 'short', + apiUrl, + fetchFn: fetch, + }), /already used|expired|invalid/i, ) }) + +test('redeemBootstrapCode rejects missing lifetime before calling the API', async () => { + reset() + await assert.rejects( + () => redeemBootstrapCode({ code: goodCode, apiUrl, fetchFn: fetch }), + /--lifetime short\|long/, + ) + assert.equal(requests.length, 0) +})