diff --git a/README.md b/README.md index 8ce433d..1b9c52a 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,34 @@ hookmyapp channels connect whatsapp # or name the type directly hookmyapp channels connect instagram ``` +## Authentication + +`hookmyapp login` signs you in through your browser and is the default for +interactive use. + +**Browser-free sign-in** (for headless environments and AI agents) uses an +emailed one-time code instead of a browser: + +```bash +# Interactive terminal: prompts you for the 6-digit code from your email +hookmyapp login --email you@example.com + +# Non-interactive / agent: two steps, because the code arrives out of band +hookmyapp login --email you@example.com --json +# -> { "registrationId": "...", "expiresAt": "..." } +hookmyapp login --email you@example.com --registration-id --otp 123456 --json +``` + +This stores an organization-scoped credential (`ac_…`). Pass `--scope ` +(repeatable) to request a narrower set than the default full access. + +Manage those credentials: + +```bash +hookmyapp credentials list +hookmyapp credentials revoke ac_ab12cd34 -y +``` + ## Listening for webhooks on localhost Two flavors — pick based on which channel you have. diff --git a/src/api/__tests__/agent-auth.test.ts b/src/api/__tests__/agent-auth.test.ts new file mode 100644 index 0000000..fac998f --- /dev/null +++ b/src/api/__tests__/agent-auth.test.ts @@ -0,0 +1,68 @@ +import { expect, test, beforeEach, afterEach, vi } from 'vitest'; + +const SAVED = process.env.HOOKMYAPP_API_URL; +beforeEach(() => { + process.env.HOOKMYAPP_API_URL = 'https://test.example.com'; +}); +afterEach(() => { + if (SAVED) process.env.HOOKMYAPP_API_URL = SAVED; + else delete process.env.HOOKMYAPP_API_URL; + vi.unstubAllGlobals(); +}); + +function okJson(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); +} + +test('fetchSupportedScopes reads scopes_supported from the well-known', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(okJson({ scopes_supported: ['workspace.read', 'message.send'] }))); + const { fetchSupportedScopes } = await import('../agent-auth.js'); + expect(await fetchSupportedScopes()).toEqual(['workspace.read', 'message.send']); +}); + +test('initiateClaim POSTs email + scopes and returns registrationId + expiresAt', async () => { + const fetchMock = vi.fn().mockResolvedValue( + okJson({ registrationId: '11111111-1111-1111-1111-111111111111', expiresAt: '2026-07-01T00:10:00.000Z', message: 'sent' }, 202), + ); + vi.stubGlobal('fetch', fetchMock); + const { initiateClaim } = await import('../agent-auth.js'); + const out = await initiateClaim({ email: 'a@b.com', scopes: ['workspace.read'] }); + const [url, init] = fetchMock.mock.calls[0]; + expect(String(url)).toBe('https://test.example.com/agent/auth/claim'); + expect(init.method).toBe('POST'); + expect(JSON.parse(init.body as string)).toEqual({ email: 'a@b.com', scopes: ['workspace.read'] }); + expect(out.registrationId).toBe('11111111-1111-1111-1111-111111111111'); +}); + +test('completeClaim POSTs registrationId + otp and returns the ac_ credential', async () => { + const fetchMock = vi.fn().mockResolvedValue( + okJson({ accessToken: 'ac_live_x', tokenType: 'Bearer', scopes: ['workspace.read'], credentialPublicId: 'ac_pub1' }), + ); + vi.stubGlobal('fetch', fetchMock); + const { completeClaim } = await import('../agent-auth.js'); + const out = await completeClaim({ registrationId: '11111111-1111-1111-1111-111111111111', otp: '123456' }); + expect(String(fetchMock.mock.calls[0][0])).toBe('https://test.example.com/agent/auth/claim/complete'); + expect(out.accessToken).toBe('ac_live_x'); + expect(out.credentialPublicId).toBe('ac_pub1'); +}); + +test('completeClaim maps a 429 to a typed rate-limit error (exitCode 6)', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(okJson({ code: 'RATE_LIMITED', message: 'slow down' }, 429))); + const { completeClaim } = await import('../agent-auth.js'); + await expect(completeClaim({ registrationId: '11111111-1111-1111-1111-111111111111', otp: '000000' })).rejects.toMatchObject({ exitCode: 6 }); +}); + +test('a request timeout maps to a NetworkError (exitCode 5)', async () => { + const timeout = Object.assign(new Error('timed out'), { name: 'TimeoutError' }); + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(timeout)); + const { initiateClaim } = await import('../agent-auth.js'); + await expect(initiateClaim({ email: 'a@b.com', scopes: ['workspace.read'] })).rejects.toMatchObject({ exitCode: 5 }); +}); + +test('the auth fetch is bounded by an abort signal', async () => { + const fetchMock = vi.fn().mockResolvedValue(okJson({ scopes_supported: [] })); + vi.stubGlobal('fetch', fetchMock); + const { fetchSupportedScopes } = await import('../agent-auth.js'); + await fetchSupportedScopes(); + expect(fetchMock.mock.calls[0][1].signal).toBeInstanceOf(AbortSignal); +}); diff --git a/src/api/__tests__/agent-refresh.test.ts b/src/api/__tests__/agent-refresh.test.ts new file mode 100644 index 0000000..634c704 --- /dev/null +++ b/src/api/__tests__/agent-refresh.test.ts @@ -0,0 +1,30 @@ +import { expect, test, beforeEach, afterEach, vi } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +let DIR: string; +const SAVED = process.env.HOOKMYAPP_CONFIG_DIR; +beforeEach(() => { + DIR = mkdtempSync(join(tmpdir(), 'hma-agent-refresh-')); + process.env.HOOKMYAPP_CONFIG_DIR = DIR; + mkdirSync(DIR, { recursive: true }); +}); +afterEach(() => { + rmSync(DIR, { recursive: true, force: true }); + if (SAVED) process.env.HOOKMYAPP_CONFIG_DIR = SAVED; + else delete process.env.HOOKMYAPP_CONFIG_DIR; + vi.unstubAllGlobals(); +}); + +test('forceTokenRefresh is a no-op for an agent credential (no WorkOS call)', async () => { + writeFileSync( + join(DIR, 'credentials.json'), + JSON.stringify({ accessToken: 'ac_live_x', refreshToken: '', expiresAt: 0, kind: 'agent', credentialPublicId: 'ac_pub1', scopes: [] }), + ); + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + const { forceTokenRefresh } = await import('../client.js'); + await expect(forceTokenRefresh('org_123')).resolves.toBeUndefined(); + expect(fetchMock).not.toHaveBeenCalled(); +}); diff --git a/src/api/agent-auth.ts b/src/api/agent-auth.ts new file mode 100644 index 0000000..a97c2ec --- /dev/null +++ b/src/api/agent-auth.ts @@ -0,0 +1,65 @@ +import { getEffectiveApiUrl } from '../config/env-profiles.js'; +import { NetworkError } from '../output/error.js'; +import { mapApiError, isNetworkFailure } from './client.js'; + +// Re-declared wire DTOs (the backend is never imported). Keep field names in +// lockstep with the auth.md endpoints; integration drift is caught by tests. +export interface ClaimInitiated { + registrationId: string; // UUID + expiresAt: string; // ISO timestamp +} + +export interface AgentCredentialResponse { + accessToken: string; // "ac_…" Bearer credential + tokenType: string; // "Bearer" + scopes: string[]; + credentialPublicId: string; + expiresAt?: string; + orgId?: string; +} + +// Auth requests must not hang an agent or CI job forever; bound every call. +const AUTH_FETCH_TIMEOUT_MS = 30_000; + +async function timedFetch(url: string, init: RequestInit): Promise { + try { + return await fetch(url, { ...init, signal: AbortSignal.timeout(AUTH_FETCH_TIMEOUT_MS) }); + } catch (err) { + // AbortSignal.timeout aborts with a TimeoutError; treat it, and any + // transport failure, as a NetworkError so the CLI exits cleanly (exit 5). + if (isNetworkFailure(err) || (err instanceof Error && err.name === 'TimeoutError')) { + throw new NetworkError(); + } + throw err; + } +} + +async function postJson(path: string, body: unknown): Promise { + const res = await timedFetch(`${getEffectiveApiUrl()}${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!res.ok) throw await mapApiError(res); + return res.json(); +} + +/** Full scope vocabulary advertised by the backend (drift-free default). */ +export async function fetchSupportedScopes(): Promise { + const res = await timedFetch( + `${getEffectiveApiUrl()}/.well-known/oauth-protected-resource`, + { method: 'GET' }, + ); + if (!res.ok) throw await mapApiError(res); + const body = (await res.json()) as { scopes_supported?: string[] }; + return Array.isArray(body.scopes_supported) ? body.scopes_supported : []; +} + +export async function initiateClaim(input: { email: string; scopes: string[] }): Promise { + const data = (await postJson('/agent/auth/claim', input)) as ClaimInitiated; + return { registrationId: data.registrationId, expiresAt: data.expiresAt }; +} + +export async function completeClaim(input: { registrationId: string; otp: string }): Promise { + return (await postJson('/agent/auth/claim/complete', input)) as AgentCredentialResponse; +} diff --git a/src/api/client.ts b/src/api/client.ts index debda5d..bf7c855 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -1,4 +1,5 @@ import { readCredentials, saveCredentials } from '../auth/store.js'; +import { isAgentCredential } from '../storage/secrets.js'; import { AuthError, ApiError, @@ -77,6 +78,11 @@ export async function forceTokenRefresh(organizationId?: string): Promise if (!creds) { throw new AuthError('Not logged in. Run: hookmyapp login'); } + // Agent (auth.md) credentials are org-scoped Bearer tokens with no refresh + // token; there is nothing to refresh and no org re-scope to perform. + if (isAgentCredential(creds)) { + return; + } try { const refreshed = await refreshToken(creds.refreshToken, organizationId); await saveCredentials(refreshed); diff --git a/src/auth/__tests__/agentmd-login.test.ts b/src/auth/__tests__/agentmd-login.test.ts new file mode 100644 index 0000000..2dbaa56 --- /dev/null +++ b/src/auth/__tests__/agentmd-login.test.ts @@ -0,0 +1,126 @@ +import { expect, test, beforeEach, afterEach, vi } from 'vitest'; +import { mkdtempSync, rmSync, readFileSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +let DIR: string; +const SAVED_DIR = process.env.HOOKMYAPP_CONFIG_DIR; +const SAVED_API_URL = process.env.HOOKMYAPP_API_URL; +function okJson(b: unknown, s = 200): Response { + return new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } }); +} +function readCreds() { + return JSON.parse(readFileSync(join(DIR, 'credentials.json'), 'utf-8')); +} + +const inputMock = vi.fn(); +vi.mock('@inquirer/prompts', () => ({ + input: (...a: unknown[]) => inputMock(...a), + select: vi.fn(), + confirm: vi.fn(), +})); + +beforeEach(() => { + DIR = mkdtempSync(join(tmpdir(), 'hma-agentlogin-')); + process.env.HOOKMYAPP_CONFIG_DIR = DIR; + process.env.HOOKMYAPP_API_URL = 'https://test.example.com'; + inputMock.mockReset(); +}); +afterEach(() => { + rmSync(DIR, { recursive: true, force: true }); + if (SAVED_DIR) process.env.HOOKMYAPP_CONFIG_DIR = SAVED_DIR; + else delete process.env.HOOKMYAPP_CONFIG_DIR; + if (SAVED_API_URL) process.env.HOOKMYAPP_API_URL = SAVED_API_URL; + else delete process.env.HOOKMYAPP_API_URL; + vi.unstubAllGlobals(); +}); + +test('interactive: claims with full scopes, prompts OTP, completes, saves ac_ credential', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(okJson({ scopes_supported: ['workspace.read', 'message.send'] })) // discovery + .mockResolvedValueOnce(okJson({ registrationId: '11111111-1111-1111-1111-111111111111', expiresAt: 'x', message: 'sent' }, 202)) // claim + .mockResolvedValueOnce(okJson({ accessToken: 'ac_live_x', tokenType: 'Bearer', scopes: ['workspace.read', 'message.send'], credentialPublicId: 'ac_pub1' })); // complete + vi.stubGlobal('fetch', fetchMock); + inputMock.mockResolvedValue('123456'); + const origIsTty = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY'); + Object.defineProperty(process.stdin, 'isTTY', { configurable: true, value: true }); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const mod = await import('../login.js'); + + try { + await mod.runAgentClaimLogin({ email: 'a@b.com' }); + + expect(JSON.parse(fetchMock.mock.calls[1][1].body).scopes).toEqual(['workspace.read', 'message.send']); + const creds = readCreds(); + expect(creds.accessToken).toBe('ac_live_x'); + expect(creds.kind).toBe('agent'); + expect(creds.credentialPublicId).toBe('ac_pub1'); + } finally { + logSpy.mockRestore(); + if (origIsTty) Object.defineProperty(process.stdin, 'isTTY', origIsTty); + else delete (process.stdin as { isTTY?: boolean }).isTTY; + } +}); + +test('json step 2 without --otp is a ValidationError and never calls the network', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + const mod = await import('../login.js'); + await expect( + mod.runAgentClaimLogin({ email: 'a@b.com', registrationId: '2222', json: true }), + ).rejects.toMatchObject({ exitCode: 2 }); + expect(fetchMock).not.toHaveBeenCalled(); + expect(inputMock).not.toHaveBeenCalled(); +}); + +test('json split step 1: no --otp prints registrationId + expiresAt and does NOT prompt or save', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(okJson({ scopes_supported: ['workspace.read'] })) + .mockResolvedValueOnce(okJson({ registrationId: '2222', expiresAt: 'later', message: 'sent' }, 202)); + vi.stubGlobal('fetch', fetchMock); + const outSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + const mod = await import('../login.js'); + + await mod.runAgentClaimLogin({ email: 'a@b.com', json: true }); + + expect(inputMock).not.toHaveBeenCalled(); + expect(existsSync(join(DIR, 'credentials.json'))).toBe(false); + const printed = outSpy.mock.calls.flat().join(''); + expect(JSON.parse(printed)).toMatchObject({ registrationId: '2222', expiresAt: 'later' }); + outSpy.mockRestore(); +}); + +test('json split step 2: --registration-id + --otp completes without a new claim call', async () => { + const fetchMock = vi.fn().mockResolvedValue( + okJson({ accessToken: 'ac_live_y', tokenType: 'Bearer', scopes: ['workspace.read'], credentialPublicId: 'ac_pub2' }), + ); + vi.stubGlobal('fetch', fetchMock); + const mod = await import('../login.js'); + + await mod.runAgentClaimLogin({ email: 'a@b.com', registrationId: '2222', otp: '654321', json: true }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(String(fetchMock.mock.calls[0][0])).toBe('https://test.example.com/agent/auth/claim/complete'); + expect(readCreds().accessToken).toBe('ac_live_y'); +}); + +test('--otp without --registration-id is a ValidationError (exit 2)', async () => { + const mod = await import('../login.js'); + await expect(mod.runAgentClaimLogin({ email: 'a@b.com', otp: '123456' })).rejects.toMatchObject({ exitCode: 2 }); +}); + +test('agent flags without --email are rejected before any browser flow', async () => { + const { Command } = await import('commander'); + const { loginCommand } = await import('../login.js'); + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + const program = new Command(); + program.exitOverride(); + program.option('--json'); + program.option('--human'); + loginCommand(program); + await expect( + program.parseAsync(['node', 'hookmyapp', 'login', '--registration-id', 'r1', '--otp', '123456', '--json']), + ).rejects.toMatchObject({ exitCode: 2 }); + expect(fetchMock).not.toHaveBeenCalled(); +}); diff --git a/src/auth/login.ts b/src/auth/login.ts index 52764a2..3e65a0b 100644 --- a/src/auth/login.ts +++ b/src/auth/login.ts @@ -477,6 +477,128 @@ export async function runBootstrapCodeExchange( await runWizard({ phone: opts.phone, next: opts.next, json: opts.json }); } +/** + * Browser-free login over the auth.md user-claimed email-OTP flow. + * + * TTY: claim -> prompt the 6-digit code -> complete, all in one call. + * Non-interactive / --json: the OTP arrives out-of-band, so this splits into + * two invocations keyed by registrationId: + * 1. `login --email --json` -> prints { registrationId, expiresAt } + * 2. `login --email --registration-id --otp --json` -> completes + * Persists an org-scoped `ac_` credential (no refresh token). + */ +export async function runAgentClaimLogin(opts: { + email: string; + otp?: string; + registrationId?: string; + scopes?: string[]; + json?: boolean; +}): Promise { + const { fetchSupportedScopes, initiateClaim, completeClaim } = await import( + '../api/agent-auth.js' + ); + + if (opts.otp && !opts.registrationId) { + throw new ValidationError( + '--otp requires --registration-id (from the first `login --email` call).', + ); + } + + // Prompting is only possible on an interactive terminal and not in --json. + const interactive = Boolean(process.stdin.isTTY) && !opts.json; + + // Split step 2 (or interactive completion): registrationId already known. + if (opts.registrationId) { + if (!opts.otp && !interactive) { + throw new ValidationError( + '--otp is required with --registration-id in non-interactive or --json mode.', + ); + } + const otp = opts.otp ?? (await promptOtp()); + await persistAgentCredential( + await completeClaim({ registrationId: opts.registrationId, otp }), + opts.email, + opts.json, + ); + return; + } + + // Step 1: request scopes (full vocabulary unless narrowed) and initiate. + const scopes = + opts.scopes && opts.scopes.length > 0 + ? opts.scopes + : await fetchSupportedScopes(); + if (scopes.length === 0) { + throw new ValidationError( + 'Could not resolve any scopes to request. Pass --scope explicitly.', + ); + } + const claim = await initiateClaim({ email: opts.email, scopes }); + + // Non-interactive: cannot prompt. Emit the handle and stop; caller completes + // with --registration-id + --otp. + if (!interactive) { + process.stdout.write( + JSON.stringify({ + ok: true, + registrationId: claim.registrationId, + expiresAt: claim.expiresAt, + next: 'login --email --registration-id --otp ', + }) + '\n', + ); + return; + } + + console.log( + `\n${c.success(icon.success)} We emailed a 6-digit code to ${opts.email}\n`, + ); + const otp = await promptOtp(); + await persistAgentCredential( + await completeClaim({ registrationId: claim.registrationId, otp }), + opts.email, + opts.json, + ); +} + +async function promptOtp(): Promise { + const { input } = await import('@inquirer/prompts'); + const value = await input({ + message: 'Enter the 6-digit code', + validate: (v: string) => + /^\d{6}$/.test(v.trim()) ? true : 'Enter the 6-digit code from the email.', + }); + return value.trim(); +} + +async function persistAgentCredential( + cred: { accessToken: string; scopes: string[]; credentialPublicId: string }, + email: string, + json?: boolean, +): Promise { + await saveCredentials({ + accessToken: cred.accessToken, + refreshToken: '', + expiresAt: 0, + kind: 'agent', + credentialPublicId: cred.credentialPublicId, + scopes: cred.scopes, + }); + if (json) { + process.stdout.write( + JSON.stringify({ + ok: true, + credentialPublicId: cred.credentialPublicId, + scopes: cred.scopes, + }) + '\n', + ); + return; + } + const n = cred.scopes.length; + console.log( + `${c.success(icon.success)} Logged in as ${email} (${n} scope${n === 1 ? '' : 's'})`, + ); +} + export function loginCommand(program: Command): void { const login = program .command('login') @@ -495,12 +617,29 @@ export function loginCommand(program: Command): void { '--code ', 'Exchange a dashboard-minted bootstrap code (zero browser interaction)', ) + .option('--email ', 'Browser-free sign-in via auth.md email code') + .option( + '--otp ', + 'The 6-digit code (with --registration-id, for non-interactive login)', + ) + .option( + '--registration-id ', + 'Registration id from the first `login --email` call', + ) + .option( + '--scope ', + 'Request specific scopes instead of the full set (repeatable)', + ) .action( async (opts: { phone?: string; wizard?: boolean; next?: string; code?: string; + email?: string; + otp?: string; + registrationId?: string; + scope?: string[]; }) => { // Reject an invalid --next locally (exit 2) instead of silently // coercing it to undefined — `login --next bogus` previously ran the @@ -522,6 +661,35 @@ export function loginCommand(program: Command): void { | undefined; const json = program.opts().json === true; + // auth.md completion flags are meaningless without --email; reject them + // up front instead of silently falling through to the browser flow. + if ( + !opts.email && + (opts.otp || opts.registrationId || (opts.scope?.length ?? 0) > 0) + ) { + throw new ValidationError( + '--otp, --registration-id, and --scope require --email.', + ); + } + + // auth.md browser-free branch. Mutually exclusive with --code/--wizard; + // runs before them so a bad combination is rejected up front. + if (opts.email) { + if (opts.wizard || opts.code) { + throw new ValidationError( + '--email cannot be combined with --code or --wizard.', + ); + } + await runAgentClaimLogin({ + email: opts.email, + otp: opts.otp, + registrationId: opts.registrationId, + scopes: opts.scope, + json, + }); + return; + } + // Phase 122 — bootstrap-code branch. MUST run BEFORE the wizard // fast-path and BEFORE device-flow initiation so --code --wizard is // flagged as a programming error (mutually exclusive). @@ -612,6 +780,9 @@ export function loginCommand(program: Command): void { ` EXAMPLES: $ hookmyapp login + $ hookmyapp login --email you@example.com # browser-free (prompts for a code) + $ hookmyapp login --email you@example.com --json # step 1: prints registrationId + $ hookmyapp login --email you@example.com --registration-id --otp 123456 --json # step 2 $ hookmyapp login --code hma_boot_xxx # zero-browser AI paste $ hookmyapp login --workspace acme-corp # preselect workspace $ hookmyapp login --next sandbox --phone +15551234567 # scripts / CI diff --git a/src/commands/__tests__/credentials.test.ts b/src/commands/__tests__/credentials.test.ts new file mode 100644 index 0000000..51d258c --- /dev/null +++ b/src/commands/__tests__/credentials.test.ts @@ -0,0 +1,81 @@ +import { expect, test, beforeEach, afterEach, vi } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +let DIR: string; +const SAVED_DIR = process.env.HOOKMYAPP_CONFIG_DIR; + +const apiClientMock = vi.fn(); +vi.mock('../../api/client.js', () => ({ apiClient: (...a: unknown[]) => apiClientMock(...a) })); +const confirmMock = vi.fn(); +vi.mock('@inquirer/prompts', () => ({ + confirm: (...a: unknown[]) => confirmMock(...a), + select: vi.fn(), + input: vi.fn(), +})); + +async function run(argv: string[]): Promise { + const { Command } = await import('commander'); + const program = new Command(); + program.option('--json'); + program.option('--human'); + const { registerCredentialsCommand } = await import('../credentials.js'); + registerCredentialsCommand(program); + await program.parseAsync(['node', 'hookmyapp', ...argv]); +} + +beforeEach(() => { + DIR = mkdtempSync(join(tmpdir(), 'hma-creds-cmd-')); + process.env.HOOKMYAPP_CONFIG_DIR = DIR; + apiClientMock.mockReset(); + confirmMock.mockReset(); +}); +afterEach(() => { + rmSync(DIR, { recursive: true, force: true }); + if (SAVED_DIR) process.env.HOOKMYAPP_CONFIG_DIR = SAVED_DIR; + else delete process.env.HOOKMYAPP_CONFIG_DIR; + vi.restoreAllMocks(); +}); + +test('list --json prints the credentials array', async () => { + apiClientMock.mockResolvedValue([{ publicId: 'ac_pub1', scopes: ['workspace.read'] }]); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + await run(['credentials', 'list', '--json']); + expect(apiClientMock).toHaveBeenCalledWith('/agent/credentials'); + expect(JSON.parse(logSpy.mock.calls.flat().join('\n'))).toEqual([{ publicId: 'ac_pub1', scopes: ['workspace.read'] }]); + logSpy.mockRestore(); +}); + +test('revoke -y DELETEs without prompting', async () => { + apiClientMock.mockResolvedValue(undefined); + await run(['credentials', 'revoke', 'ac_pub1', '-y']); + expect(confirmMock).not.toHaveBeenCalled(); + expect(apiClientMock).toHaveBeenCalledWith('/agent/credentials/ac_pub1', { method: 'DELETE' }); +}); + +test('revoke aborts when the user declines the prompt', async () => { + confirmMock.mockResolvedValue(false); + await run(['credentials', 'revoke', 'ac_pub1']); + expect(apiClientMock).not.toHaveBeenCalled(); +}); + +test('revoking the currently stored credential clears it from disk', async () => { + writeFileSync( + join(DIR, 'credentials.json'), + JSON.stringify({ accessToken: 'ac_x', refreshToken: '', expiresAt: 0, kind: 'agent', credentialPublicId: 'ac_pub1', scopes: [] }), + ); + apiClientMock.mockResolvedValue(undefined); + await run(['credentials', 'revoke', 'ac_pub1', '-y', '--json']); + expect(existsSync(join(DIR, 'credentials.json'))).toBe(false); +}); + +test('revoking a different credential leaves the stored one intact', async () => { + writeFileSync( + join(DIR, 'credentials.json'), + JSON.stringify({ accessToken: 'ac_x', refreshToken: '', expiresAt: 0, kind: 'agent', credentialPublicId: 'ac_pub1', scopes: [] }), + ); + apiClientMock.mockResolvedValue(undefined); + await run(['credentials', 'revoke', 'ac_other', '-y', '--json']); + expect(existsSync(join(DIR, 'credentials.json'))).toBe(true); +}); diff --git a/src/commands/credentials.ts b/src/commands/credentials.ts new file mode 100644 index 0000000..9b7e2ba --- /dev/null +++ b/src/commands/credentials.ts @@ -0,0 +1,98 @@ +import { Command } from 'commander'; +import { apiClient } from '../api/client.js'; +import { readCredentials, deleteCredentials } from '../auth/store.js'; +import { isAgentCredential } from '../storage/secrets.js'; +import { addExamples } from '../output/help.js'; + +interface AgentCredentialRow { + publicId: string; + scopes?: string[]; +} + +/** Manage auth.md agent credentials (ac_ Bearer tokens). */ +export function registerCredentialsCommand(program: Command): void { + const credentials = program + .command('credentials') + .description('List and revoke agent credentials'); + + const list = credentials + .command('list') + .description('List your agent credentials') + .action(async () => { + const data = await apiClient('/agent/credentials'); + if (program.opts().json) { + console.log(JSON.stringify(data, null, 2)); + return; + } + const rows: AgentCredentialRow[] = Array.isArray(data) ? data : []; + if (rows.length === 0) { + console.log( + 'No agent credentials. Create one with: hookmyapp login --email ', + ); + return; + } + for (const r of rows) { + console.log(`${r.publicId} ${(r.scopes ?? []).join(', ')}`); + } + }); + + const revoke = credentials + .command('revoke ') + .description('Revoke an agent credential') + .option('-y, --yes', 'Skip the confirmation prompt') + .action(async (publicId: string, opts: { yes?: boolean }) => { + const isJson = Boolean(program.opts().json); + if (!opts.yes && !isJson) { + const { confirm } = await import('@inquirer/prompts'); + const ok = await confirm({ + message: `Revoke credential ${publicId}?`, + default: false, + }); + if (!ok) { + console.log('Aborted.'); + return; + } + } + await apiClient(`/agent/credentials/${encodeURIComponent(publicId)}`, { + method: 'DELETE', + }); + // If this is the credential we're currently authenticated with, drop the + // now-dead token from disk so the next command doesn't send a 401. + const creds = await readCredentials(); + if (creds && isAgentCredential(creds) && creds.credentialPublicId === publicId) { + await deleteCredentials(); + } + if (isJson) { + console.log(JSON.stringify({ ok: true, revoked: publicId })); + return; + } + console.log(`Revoked ${publicId}`); + }); + + addExamples( + credentials, + ` +EXAMPLES: + $ hookmyapp credentials list + $ hookmyapp credentials revoke ac_ab12cd34 -y +`, + ); + + addExamples( + list, + ` +EXAMPLES: + $ hookmyapp credentials list + $ hookmyapp credentials list --json +`, + ); + + addExamples( + revoke, + ` +EXAMPLES: + $ hookmyapp credentials revoke ac_ab12cd34 + $ hookmyapp credentials revoke ac_ab12cd34 -y +`, + ); +} diff --git a/src/index.ts b/src/index.ts index 5ec52f8..731110c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ import { fileURLToPath } from 'node:url'; import { Command, CommanderError, Option } from 'commander'; import { loginCommand } from './auth/login.js'; import { logoutCommand } from './auth/logout.js'; +import { registerCredentialsCommand } from './commands/credentials.js'; import { registerChannelsCommand } from './commands/channels.js'; import { registerWhatsappCommand, registerWhatsappMessages } from './commands/whatsapp.js'; import { registerWhatsappTemplates } from './commands/whatsapp-templates.js'; @@ -159,6 +160,7 @@ program.configureOutput({ // Auth commands loginCommand(program); logoutCommand(program); +registerCredentialsCommand(program); // Channel management registerChannelsCommand(program); diff --git a/src/storage/__tests__/agent-secrets.test.ts b/src/storage/__tests__/agent-secrets.test.ts new file mode 100644 index 0000000..000c0ee --- /dev/null +++ b/src/storage/__tests__/agent-secrets.test.ts @@ -0,0 +1,37 @@ +import { expect, test, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +let DIR: string; +const SAVED = process.env.HOOKMYAPP_CONFIG_DIR; +beforeEach(() => { + DIR = mkdtempSync(join(tmpdir(), 'hma-agent-secrets-')); + process.env.HOOKMYAPP_CONFIG_DIR = DIR; +}); +afterEach(() => { + rmSync(DIR, { recursive: true, force: true }); + if (SAVED) process.env.HOOKMYAPP_CONFIG_DIR = SAVED; + else delete process.env.HOOKMYAPP_CONFIG_DIR; +}); + +test('round-trips an agent credential and flags it', async () => { + const { writeSecrets, readSecrets, isAgentCredential } = await import('../secrets.js'); + await writeSecrets({ + accessToken: 'ac_abc', + refreshToken: '', + expiresAt: 0, + kind: 'agent', + credentialPublicId: 'ac_pub1', + scopes: ['workspace.read'], + }); + const got = await readSecrets(); + expect(got?.accessToken).toBe('ac_abc'); + expect(got?.credentialPublicId).toBe('ac_pub1'); + expect(isAgentCredential(got!)).toBe(true); +}); + +test('a legacy workos credential is not flagged as agent', async () => { + const { isAgentCredential } = await import('../secrets.js'); + expect(isAgentCredential({ accessToken: 'ey.j.s', refreshToken: 'r', expiresAt: 123 })).toBe(false); +}); diff --git a/src/storage/secrets.ts b/src/storage/secrets.ts index 2b4be76..206b516 100644 --- a/src/storage/secrets.ts +++ b/src/storage/secrets.ts @@ -24,6 +24,17 @@ export interface Secrets { accessToken: string; refreshToken: string; expiresAt: number; + /** Credential kind. Undefined = legacy WorkOS session (device-code / bootstrap). */ + kind?: 'workos' | 'agent'; + /** Agent credentials only: the ac_ credential's public id (for revoke). */ + credentialPublicId?: string; + /** Agent credentials only: scopes granted at issue time. */ + scopes?: string[]; +} + +/** True for an auth.md-issued org-scoped `ac_` credential (no refresh token). */ +export function isAgentCredential(creds: Secrets): boolean { + return creds.kind === 'agent'; } export async function writeSecrets(secrets: Secrets): Promise {