From d0e946e30fb54d9467cc64eea98f40d35bd3ea18 Mon Sep 17 00:00:00 2001 From: Preston Seay Date: Mon, 22 Jun 2026 11:44:16 -0700 Subject: [PATCH 01/11] Add `latchkey prepare` command and PKCE for Google OAuth Add a top-level `latchkey prepare ` command that stores a service's credentials from a validated JSON payload. Services opt in via an optional `prepareFromJson` hook backed by a Zod schema (the base default is "not supported"); Google services accept `{clientId, clientSecret}` and store token-less OAuth credentials. This lets users register the official Imbue OAuth client directly instead of minting their own via the Cloud Console. The older `auth browser-prepare` flow remains as a fallback. Wire PKCE (S256) into the Google OAuth login flow: the authorization request now sends code_challenge/code_challenge_method and the token exchange sends the matching code_verifier, while still sending the client secret (confidential desktop client + PKCE, defense-in-depth). - Forward `prepare` through gateway mode like `browser-prepare` - Update Google service info strings + README to recommend `prepare` - Tests for prepareService, the CLI command, the gateway endpoint, and PKCE Co-authored-by: Sculptor Co-Authored-By: Claude Opus 4.8 --- README.md | 4 + src/cliCommands.ts | 45 ++++++++++++ src/gateway/latchkeyEndpoint.ts | 26 ++++++- src/services/core/base.ts | 65 ++++++++++++++++ src/services/google/analytics.ts | 3 +- src/services/google/base.ts | 42 ++++++++++- src/services/google/calendar.ts | 3 +- src/services/google/docs.ts | 3 +- src/services/google/drive.ts | 3 +- src/services/google/gmail.ts | 3 +- src/services/google/people.ts | 3 +- src/services/google/sheets.ts | 3 +- src/services/index.ts | 2 + src/sharedOperations.ts | 50 ++++++++++++- tests/cli.test.ts | 101 +++++++++++++++++++++++++ tests/latchkeyEndpoint.test.ts | 68 +++++++++++++++++ tests/oauthUtils.test.ts | 23 +++++- tests/sharedOperations.test.ts | 122 ++++++++++++++++++++++++++++++- 18 files changed, 553 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 867f330..c3e1111 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,10 @@ Latchkey is a command-line tool that injects credentials into curl commands. - Open a browser login pop-up window and store the resulting API credentials. - This also allows agents to prompt users for credentials. - Only some services support this option. +- `latchkey prepare ` + - Store a service's credentials directly from a validated JSON payload (e.g. an OAuth `{"clientId":"...","clientSecret":"..."}`). + - For Google services this is the recommended way to register the official Imbue OAuth client, skipping the browser-based client setup. Run `latchkey auth browser ` afterward to complete login. + - The older `latchkey auth browser-prepare ` (which creates your own OAuth client via the Google Cloud Console) remains available as a fallback. Latchkey is primarily designed for AI agents. By invoking Latchkey, agents can utilize user-provided credentials or prompt diff --git a/src/cliCommands.ts b/src/cliCommands.ts index 1afa152..20a7fd2 100644 --- a/src/cliCommands.ts +++ b/src/cliCommands.ts @@ -46,6 +46,8 @@ import { LoginCancelledError, LoginFailedError, NoCurlCredentialsNotSupportedError, + PrepareInputInvalidError, + PrepareNotSupportedError, Service, } from './services/index.js'; import { @@ -77,6 +79,7 @@ import { authList, authBrowser, authBrowserPrepare, + prepareService, UnknownServiceError, BrowserNotConfiguredError, PreparationRequiredError, @@ -734,6 +737,48 @@ export function registerCommands(program: Command, deps: CliDependencies): void } }); + program + .command('prepare') + .description("Store a service's credentials from a JSON payload (e.g. an OAuth client id/secret).") + .argument('', 'Name of the service to prepare') + .argument( + '', + 'Service-specific credential JSON, e.g. \'{"clientId":"...","clientSecret":"..."}\'' + ) + .addHelpText( + 'after', + `\nExample:\n $ latchkey prepare google-gmail '{"clientId":"","clientSecret":""}'` + ) + .action(async (serviceName: string, json: string) => { + if (deps.config.gatewayUrl !== null) { + await forwardToGateway(deps, { + command: 'prepare', + params: { serviceName, json }, + }); + deps.log(`Prepared ${serviceName}.`); + return; + } + try { + const encryptedStorage = await createEncryptedStorageFromConfig(deps.config); + const apiCredentialStore = new ApiCredentialStore( + deps.config.credentialStorePath, + encryptedStorage + ); + const result = prepareService(deps.registry, apiCredentialStore, serviceName, json); + deps.log(`Prepared ${result.serviceName}.`); + } catch (error) { + if ( + error instanceof UnknownServiceError || + error instanceof PrepareNotSupportedError || + error instanceof PrepareInputInvalidError + ) { + deps.errorLog(`Error: ${error.message}`); + deps.exit(1); + } + throw error; + } + }); + program .command('curl') .description('Run curl with API credential injection.') diff --git a/src/gateway/latchkeyEndpoint.ts b/src/gateway/latchkeyEndpoint.ts index 0e4f059..341ea5d 100644 --- a/src/gateway/latchkeyEndpoint.ts +++ b/src/gateway/latchkeyEndpoint.ts @@ -17,6 +17,7 @@ import { authList, authBrowser, authBrowserPrepare, + prepareService, UnknownServiceError, BrowserNotConfiguredError, PreparationRequiredError, @@ -26,7 +27,12 @@ import { BrowserFlowsNotSupportedError, GraphicalEnvironmentNotFoundError, } from '../playwrightUtils.js'; -import { LoginCancelledError, LoginFailedError } from '../services/index.js'; +import { + LoginCancelledError, + LoginFailedError, + PrepareInputInvalidError, + PrepareNotSupportedError, +} from '../services/index.js'; const serviceNameParamsMessage = "missing required argument 'service_name'"; const serviceNameParams = z.object( @@ -68,12 +74,20 @@ const AuthBrowserPrepareRequestSchema = z.object({ params: serviceNameParams, }); +const PrepareRequestSchema = z.object({ + command: z.literal('prepare'), + params: serviceNameParams.extend({ + json: z.string(), + }), +}); + export const LatchkeyRequestSchema = z.discriminatedUnion('command', [ ServicesListRequestSchema, ServicesInfoRequestSchema, AuthListRequestSchema, AuthBrowserRequestSchema, AuthBrowserPrepareRequestSchema, + PrepareRequestSchema, ]); export type LatchkeyRequest = z.infer; @@ -87,6 +101,8 @@ const KNOWN_ERROR_CLASSES: readonly (abstract new (...args: never[]) => Error)[] PreparationRequiredError, LoginCancelledError, LoginFailedError, + PrepareNotSupportedError, + PrepareInputInvalidError, ]; function isKnownError(error: unknown): error is Error { @@ -158,6 +174,14 @@ async function dispatch( deps.config, parsed.params.serviceName ); + + case 'prepare': + return prepareService( + deps.registry, + apiCredentialStore, + parsed.params.serviceName, + parsed.params.json + ); } } diff --git a/src/services/core/base.ts b/src/services/core/base.ts index 0796d70..e7f7481 100644 --- a/src/services/core/base.ts +++ b/src/services/core/base.ts @@ -3,6 +3,7 @@ */ import type { Browser, BrowserContext, Page, Response } from 'playwright'; +import type { z, ZodTypeAny } from 'zod'; import { ApiCredentialStatus, ApiCredentials, @@ -38,6 +39,58 @@ export class LoginFailedError extends Error { } } +/** + * Thrown when `latchkey prepare` is run for a service that does not declare a + * prepare schema (the base default — services opt in by setting one). + */ +export class PrepareNotSupportedError extends Error { + constructor(serviceName: string) { + super( + `Service '${serviceName}' does not support 'latchkey prepare'. ` + + `Use 'latchkey services info ${serviceName}' to see how to authenticate.` + ); + this.name = 'PrepareNotSupportedError'; + } +} + +/** + * Thrown when the JSON passed to `latchkey prepare` is malformed or does not + * match the service's prepare schema. The whole command is rejected and + * nothing is stored. + */ +export class PrepareInputInvalidError extends Error { + constructor(serviceName: string, detail: string) { + super(`Invalid prepare input for '${serviceName}': ${detail}`); + this.name = 'PrepareInputInvalidError'; + } +} + +/** + * Validate a parsed JSON value against a service's prepare schema and build the + * resulting credentials. Centralizes validation so each service's + * `prepareFromJson` only expresses its schema and build step. Throws + * `PrepareInputInvalidError` (with the failing fields) on any schema mismatch; + * nothing is built unless the input fully validates. + */ +export function buildPreparedCredentials( + serviceName: string, + schema: Schema, + parsedJson: unknown, + build: (validatedInput: z.infer) => ApiCredentials +): ApiCredentials { + const result = schema.safeParse(parsedJson); + if (!result.success) { + const detail = result.error.issues + .map((issue) => { + const path = issue.path.join('.'); + return path ? `${path}: ${issue.message}` : issue.message; + }) + .join('; '); + throw new PrepareInputInvalidError(serviceName, detail); + } + return build(result.data as z.infer); +} + export function isBrowserClosedError(error: Error): boolean { const message = error.message.toLowerCase(); return ( @@ -126,6 +179,18 @@ export abstract class Service { throw new NoCurlCredentialsNotSupportedError(this.name); } + /** + * Build credentials from a parsed JSON payload for `latchkey prepare`. + * + * Optional, like `getSession`/`refreshCredentials`: services opt in by + * implementing it (typically via `buildPreparedCredentials` with a Zod + * schema). When a service does not implement it, prepare is "not supported" + * — the default that lets every service stay closed until it declares a + * schema. Implementations validate `parsedJson` and throw + * `PrepareInputInvalidError` on mismatch. + */ + prepareFromJson?(parsedJson: unknown): ApiCredentials; + /** * Get a new session for the login flow. * Services that don't support browser login should not implement this method. diff --git a/src/services/google/analytics.ts b/src/services/google/analytics.ts index 3046d49..2d49c5e 100644 --- a/src/services/google/analytics.ts +++ b/src/services/google/analytics.ts @@ -14,7 +14,8 @@ export class GoogleAnalytics extends GoogleService { ] as const; readonly info = 'https://developers.google.com/analytics/devguides/reporting/data/v1. ' + - 'If needed, run "latchkey auth browser-prepare google-analytics" to create an OAuth client first. ' + + 'To authenticate, run "latchkey prepare google-analytics" with the official OAuth client id/secret (recommended), ' + + 'or "latchkey auth browser-prepare google-analytics" to create your own client first. ' + 'It may take a few minutes before the OAuth client is ready to use.'; readonly credentialCheckCurlArguments = [ diff --git a/src/services/google/base.ts b/src/services/google/base.ts index 3e8fd18..0973d0a 100644 --- a/src/services/google/base.ts +++ b/src/services/google/base.ts @@ -20,12 +20,15 @@ import { } from '../../playwrightUtils.js'; import { exchangeCodeForTokens, + generateCodeChallenge, + generateCodeVerifier, refreshAccessToken, startOAuthCallbackServer, } from '../../oauthUtils.js'; import { Service, BrowserFollowupServiceSession, + buildPreparedCredentials, LoginFailedError, LoginCancelledError, isBrowserClosedError, @@ -773,6 +776,13 @@ class GoogleServiceSession extends BrowserFollowupServiceSession { ); const redirectUri = `http://localhost:${port.toString()}/oauth2callback`; + // PKCE (RFC 7636): bind the authorization code to a one-time verifier so a + // stolen code cannot be redeemed without it. We keep sending the client + // secret too — the official client is a Google "Desktop app" client, so + // this is confidential-client + PKCE, defense-in-depth. + const codeVerifier = generateCodeVerifier(); + const codeChallenge = generateCodeChallenge(codeVerifier); + const authUrl = new URL('https://accounts.google.com/o/oauth2/v2/auth'); authUrl.searchParams.set('client_id', clientId); authUrl.searchParams.set('redirect_uri', redirectUri); @@ -780,6 +790,8 @@ class GoogleServiceSession extends BrowserFollowupServiceSession { authUrl.searchParams.set('scope', allScopes.join(' ')); authUrl.searchParams.set('access_type', 'offline'); authUrl.searchParams.set('prompt', 'consent'); + authUrl.searchParams.set('code_challenge', codeChallenge); + authUrl.searchParams.set('code_challenge_method', 'S256'); await page.goto(authUrl.toString()); @@ -789,7 +801,8 @@ class GoogleServiceSession extends BrowserFollowupServiceSession { code, clientId, clientSecret, - redirectUri + redirectUri, + codeVerifier ); const accessTokenExpiresAt = new Date(Date.now() + tokens.expires_in * 1000).toISOString(); @@ -810,6 +823,20 @@ class GoogleServiceSession extends BrowserFollowupServiceSession { } } +/** + * JSON accepted by `latchkey prepare `: the OAuth client + * credentials to use for that service. `.strict()` rejects unknown keys so + * typos are reported instead of silently ignored. + */ +export const GooglePrepareInputSchema = z + .object({ + clientId: z.string().min(1), + clientSecret: z.string().min(1), + }) + .strict(); + +export type GooglePrepareInput = z.infer; + /** * Abstract base class for individual Google API services. * @@ -822,6 +849,19 @@ export abstract class GoogleService extends Service { protected abstract readonly config: GoogleServiceConfig; + /** + * Google services accept an official OAuth client's id/secret via + * `latchkey prepare`, stored as token-less OAuth credentials until login. + */ + override prepareFromJson(parsedJson: unknown): ApiCredentials { + return buildPreparedCredentials( + this.name, + GooglePrepareInputSchema, + parsedJson, + ({ clientId, clientSecret }) => new OAuthCredentials(clientId, clientSecret) + ); + } + setCredentialsExample(serviceName: string): string { return `latchkey auth set ${serviceName} -H "Authorization: Bearer "`; } diff --git a/src/services/google/calendar.ts b/src/services/google/calendar.ts index 7440769..6ce5873 100644 --- a/src/services/google/calendar.ts +++ b/src/services/google/calendar.ts @@ -14,7 +14,8 @@ export class GoogleCalendar extends GoogleService { readonly baseApiUrls = ['https://www.googleapis.com/calendar/'] as const; readonly info = 'https://developers.google.com/calendar/api/v3/reference. ' + - 'If needed, run "latchkey auth browser-prepare google-calendar" to create an OAuth client first. ' + + 'To authenticate, run "latchkey prepare google-calendar" with the official OAuth client id/secret (recommended), ' + + 'or "latchkey auth browser-prepare google-calendar" to create your own client first. ' + 'It may take a few minutes before the OAuth client is ready to use.'; readonly credentialCheckCurlArguments = [ diff --git a/src/services/google/docs.ts b/src/services/google/docs.ts index 6d481d0..28a5a3f 100644 --- a/src/services/google/docs.ts +++ b/src/services/google/docs.ts @@ -14,7 +14,8 @@ export class GoogleDocs extends GoogleService { readonly baseApiUrls = ['https://docs.googleapis.com/'] as const; readonly info = 'https://developers.google.com/docs/api/reference/rest. ' + - 'If needed, run "latchkey auth browser-prepare google-docs" to create an OAuth client first. ' + + 'To authenticate, run "latchkey prepare google-docs" with the official OAuth client id/secret (recommended), ' + + 'or "latchkey auth browser-prepare google-docs" to create your own client first. ' + 'It may take a few minutes before the OAuth client is ready to use.'; readonly credentialCheckCurlArguments = [ diff --git a/src/services/google/drive.ts b/src/services/google/drive.ts index b2ed242..1be5849 100644 --- a/src/services/google/drive.ts +++ b/src/services/google/drive.ts @@ -11,7 +11,8 @@ export class GoogleDrive extends GoogleService { readonly baseApiUrls = ['https://www.googleapis.com/drive/'] as const; readonly info = 'https://developers.google.com/drive/api/reference/rest/v3. ' + - 'If needed, run "latchkey auth browser-prepare google-drive" to create an OAuth client first. ' + + 'To authenticate, run "latchkey prepare google-drive" with the official OAuth client id/secret (recommended), ' + + 'or "latchkey auth browser-prepare google-drive" to create your own client first. ' + 'It may take a few minutes before the OAuth client is ready to use.'; readonly credentialCheckCurlArguments = [ diff --git a/src/services/google/gmail.ts b/src/services/google/gmail.ts index 261915c..113c71a 100644 --- a/src/services/google/gmail.ts +++ b/src/services/google/gmail.ts @@ -16,7 +16,8 @@ export class GoogleGmail extends GoogleService { readonly baseApiUrls = ['https://gmail.googleapis.com/'] as const; readonly info = 'https://developers.google.com/gmail/api/reference/rest. ' + - 'If needed, run "latchkey auth browser-prepare google-gmail" to create an OAuth client first. ' + + 'To authenticate, run "latchkey prepare google-gmail" with the official OAuth client id/secret (recommended), ' + + 'or "latchkey auth browser-prepare google-gmail" to create your own client first. ' + 'It may take a few minutes before the OAuth client is ready to use.'; readonly credentialCheckCurlArguments = [ diff --git a/src/services/google/people.ts b/src/services/google/people.ts index 87659a3..39046d2 100644 --- a/src/services/google/people.ts +++ b/src/services/google/people.ts @@ -14,7 +14,8 @@ export class GooglePeople extends GoogleService { readonly baseApiUrls = ['https://people.googleapis.com/'] as const; readonly info = 'https://developers.google.com/people/api/rest. ' + - 'If needed, run "latchkey auth browser-prepare google-people" to create an OAuth client first. ' + + 'To authenticate, run "latchkey prepare google-people" with the official OAuth client id/secret (recommended), ' + + 'or "latchkey auth browser-prepare google-people" to create your own client first. ' + 'It may take a few minutes before the OAuth client is ready to use.'; readonly credentialCheckCurlArguments = [ diff --git a/src/services/google/sheets.ts b/src/services/google/sheets.ts index 64e0faf..736eeed 100644 --- a/src/services/google/sheets.ts +++ b/src/services/google/sheets.ts @@ -14,7 +14,8 @@ export class GoogleSheets extends GoogleService { readonly baseApiUrls = ['https://sheets.googleapis.com/'] as const; readonly info = 'https://developers.google.com/sheets/api/reference/rest. ' + - 'If needed, run "latchkey auth browser-prepare google-sheets" to create an OAuth client first. ' + + 'To authenticate, run "latchkey prepare google-sheets" with the official OAuth client id/secret (recommended), ' + + 'or "latchkey auth browser-prepare google-sheets" to create your own client first. ' + 'It may take a few minutes before the OAuth client is ready to use.'; readonly credentialCheckCurlArguments = [ diff --git a/src/services/index.ts b/src/services/index.ts index e9941a5..8d742f5 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -12,6 +12,8 @@ export { LoginCancelledError, LoginFailedError, NoCurlCredentialsNotSupportedError, + PrepareNotSupportedError, + PrepareInputInvalidError, } from './core/base.js'; export { RegisteredService } from './core/registered.js'; diff --git a/src/sharedOperations.ts b/src/sharedOperations.ts index 27b8e66..5eb37da 100644 --- a/src/sharedOperations.ts +++ b/src/sharedOperations.ts @@ -19,7 +19,12 @@ import { hasGraphicalEnvironment, } from './playwrightUtils.js'; import type { ServiceRegistry } from './serviceRegistry.js'; -import { isBrowserClosedError, LoginCancelledError } from './services/core/base.js'; +import { + isBrowserClosedError, + LoginCancelledError, + PrepareInputInvalidError, + PrepareNotSupportedError, +} from './services/core/base.js'; import { RegisteredService } from './services/core/registered.js'; import type { Service } from './services/index.js'; @@ -252,3 +257,46 @@ export async function authBrowserPrepare( apiCredentialStore.save(service.name, apiCredentials); return { alreadyPrepared: false }; } + +export interface PrepareServiceResult { + readonly serviceName: string; + readonly credentialType: string; +} + +/** + * Store credentials for a service from a validated JSON payload + * (`latchkey prepare `). The whole operation is rejected — and + * nothing is stored — if the JSON is malformed, fails the service's schema, or + * the service does not support prepare. + */ +export function prepareService( + registry: ServiceRegistry, + apiCredentialStore: ApiCredentialStore, + serviceName: string, + json: string +): PrepareServiceResult { + const service = lookupService(registry, serviceName); + + // Services opt in to prepare by implementing prepareFromJson; absence is the + // default "not supported" state. + if (service.prepareFromJson === undefined) { + throw new PrepareNotSupportedError(serviceName); + } + + let parsedJson: unknown; + try { + parsedJson = JSON.parse(json); + } catch (error: unknown) { + throw new PrepareInputInvalidError( + serviceName, + `not valid JSON: ${error instanceof Error ? error.message : String(error)}` + ); + } + + // prepareFromJson validates against the service's schema and throws + // PrepareInputInvalidError on any mismatch, so a store only happens once the + // input is fully valid. + const credentials = service.prepareFromJson(parsedJson); + apiCredentialStore.save(service.name, credentials); + return { serviceName: service.name, credentialType: credentials.objectType }; +} diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 8d638ab..19e5db1 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -21,6 +21,7 @@ import { verifyPermissionsOverrideJwt, } from '../src/gateway/permissionsOverride.js'; import { TELEGRAM } from '../src/services/telegram.js'; +import { GOOGLE_GMAIL } from '../src/services/google/gmail.js'; import { deleteRegisteredService, loadRegisteredServices, @@ -1194,6 +1195,76 @@ describe('CLI commands with dependency injection', () => { }); }); + describe('prepare command', () => { + it('stores an OAuth client for a Google service and reports success', async () => { + const storePath = join(tempDir, 'credentials.json'); + writeSecureFile(storePath, '{}'); + + const deps = createMockDependencies({ + registry: new ServiceRegistry([GOOGLE_GMAIL]), + }); + + await runCommand( + ['prepare', 'google-gmail', '{"clientId":"cid","clientSecret":"csecret"}'], + deps + ); + + expect(logs).toContain('Prepared google-gmail.'); + const storedData = JSON.parse(readSecureFile(storePath) ?? '{}') as Record; + expect(storedData['google-gmail']).toEqual({ + objectType: 'oauth', + clientId: 'cid', + clientSecret: 'csecret', + }); + }); + + it('returns an error for an unknown service', async () => { + const deps = createMockDependencies(); + + await runCommand(['prepare', 'unknown-service', '{}'], deps); + + expect(exitCode).toBe(1); + }); + + it('returns an error for a service that does not support prepare', async () => { + const deps = createMockDependencies(); + + await runCommand(['prepare', 'slack', '{"clientId":"a","clientSecret":"b"}'], deps); + + expect(exitCode).toBe(1); + }); + + it('returns an error and stores nothing for malformed JSON', async () => { + const storePath = join(tempDir, 'credentials.json'); + writeSecureFile(storePath, '{}'); + + const deps = createMockDependencies({ + registry: new ServiceRegistry([GOOGLE_GMAIL]), + }); + + await runCommand(['prepare', 'google-gmail', '{not valid'], deps); + + expect(exitCode).toBe(1); + const storedData = JSON.parse(readSecureFile(storePath) ?? '{}') as Record; + expect(storedData['google-gmail']).toBeUndefined(); + }); + + it('returns an error for input that fails the schema', async () => { + const storePath = join(tempDir, 'credentials.json'); + writeSecureFile(storePath, '{}'); + + const deps = createMockDependencies({ + registry: new ServiceRegistry([GOOGLE_GMAIL]), + }); + + await runCommand(['prepare', 'google-gmail', '{"clientId":"only-id"}'], deps); + + expect(exitCode).toBe(1); + const storedData = JSON.parse(readSecureFile(storePath) ?? '{}') as Record; + expect(storedData['google-gmail']).toBeUndefined(); + }); + }); + describe('curl command', () => { it('should pass arguments to subprocess', async () => { const storePath = join(tempDir, 'credentials.json'); @@ -2456,12 +2527,42 @@ describe('CLI commands with dependency injection', () => { expect(logs).toContain('Already prepared.'); }); + it('forwards `prepare` to the gateway /latchkey endpoint', async () => { + const fetchMock = makeFetchMock( + new Response( + JSON.stringify({ result: { serviceName: 'google-gmail', credentialType: 'oauth' } }), + { status: 200 } + ) + ); + const deps = createMockDependencies({ + config: createMockConfig({ gatewayUrl: GATEWAY_URL }), + }); + + await runCommand( + ['prepare', 'google-gmail', '{"clientId":"cid","clientSecret":"csecret"}'], + deps + ); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe(`${GATEWAY_URL}/latchkey`); + expect(JSON.parse(init.body as string) as unknown).toEqual({ + command: 'prepare', + params: { + serviceName: 'google-gmail', + json: '{"clientId":"cid","clientSecret":"csecret"}', + }, + }); + expect(logs).toContain('Prepared google-gmail.'); + }); + it.each([ ['services list', ['services', 'list']], ['services info', ['services', 'info', 'foo']], ['auth list', ['auth', 'list']], ['auth browser', ['auth', 'browser', 'slack']], ['auth browser-prepare', ['auth', 'browser-prepare', 'slack']], + ['prepare', ['prepare', 'foo', '{}']], ])('reports gateway errors on stderr (not stdout) for `%s`', async (_name, argv) => { makeFetchMock( new Response(JSON.stringify({ error: 'Unknown service: foo.' }), { status: 400 }) diff --git a/tests/latchkeyEndpoint.test.ts b/tests/latchkeyEndpoint.test.ts index 4d09ded..d0edff3 100644 --- a/tests/latchkeyEndpoint.test.ts +++ b/tests/latchkeyEndpoint.test.ts @@ -7,6 +7,7 @@ import { derivePermissionsOverrideSigningKey } from '../src/gateway/permissionsO import { ApiCredentialStore } from '../src/apiCredentials/store.js'; import { ApiCredentialStatus } from '../src/apiCredentials/base.js'; import { NoCurlCredentialsNotSupportedError, Service } from '../src/services/core/base.js'; +import { GOOGLE_GMAIL } from '../src/services/google/gmail.js'; import { ServiceRegistry } from '../src/serviceRegistry.js'; import { Config } from '../src/config.js'; import type { CliDependencies } from '../src/cliCommands.js'; @@ -91,6 +92,30 @@ describe('LatchkeyRequestSchema', () => { expect(result.success).toBe(true); }); + it('should validate prepare with serviceName and json', () => { + const result = LatchkeyRequestSchema.safeParse({ + command: 'prepare', + params: { serviceName: 'google-gmail', json: '{"clientId":"a","clientSecret":"b"}' }, + }); + expect(result.success).toBe(true); + }); + + it('should reject prepare without json', () => { + const result = LatchkeyRequestSchema.safeParse({ + command: 'prepare', + params: { serviceName: 'google-gmail' }, + }); + expect(result.success).toBe(false); + }); + + it('should reject prepare without serviceName', () => { + const result = LatchkeyRequestSchema.safeParse({ + command: 'prepare', + params: { json: '{}' }, + }); + expect(result.success).toBe(false); + }); + it('should reject unknown command', () => { const result = LatchkeyRequestSchema.safeParse({ command: 'unknown command', @@ -319,6 +344,49 @@ describe('/latchkey/ endpoint', () => { }); }); + describe('prepare', () => { + it('stores OAuth client credentials for a Google service', async () => { + gateway = await createTestGateway({}, { registry: new ServiceRegistry([GOOGLE_GMAIL]) }); + const response = await postLatchkey({ + command: 'prepare', + params: { + serviceName: 'google-gmail', + json: '{"clientId":"cid","clientSecret":"csecret"}', + }, + }); + + expect(response.status).toBe(200); + const body = (await response.json()) as { + result: { serviceName: string; credentialType: string }; + }; + expect(body.result).toEqual({ serviceName: 'google-gmail', credentialType: 'oauth' }); + }); + + it('returns 400 when the service does not support prepare', async () => { + gateway = await createTestGateway(); + const response = await postLatchkey({ + command: 'prepare', + params: { serviceName: 'slack', json: '{"clientId":"a","clientSecret":"b"}' }, + }); + + expect(response.status).toBe(400); + const body = (await response.json()) as { error: string }; + expect(body.error).toContain('does not support'); + }); + + it('returns 400 for malformed prepare JSON', async () => { + gateway = await createTestGateway({}, { registry: new ServiceRegistry([GOOGLE_GMAIL]) }); + const response = await postLatchkey({ + command: 'prepare', + params: { serviceName: 'google-gmail', json: '{not valid' }, + }); + + expect(response.status).toBe(400); + const body = (await response.json()) as { error: string }; + expect(body.error).toContain('Invalid prepare input'); + }); + }); + describe('auth list', () => { it('should return empty object when no credentials stored', async () => { gateway = await createTestGateway(); diff --git a/tests/oauthUtils.test.ts b/tests/oauthUtils.test.ts index 72b6af4..3fdd6ac 100644 --- a/tests/oauthUtils.test.ts +++ b/tests/oauthUtils.test.ts @@ -23,14 +23,29 @@ describe('startOAuthCallbackServer', () => { }); describe('generateCodeVerifier', () => { - it('just runs', () => { - generateCodeVerifier(); + it('produces a URL-safe base64 verifier with no padding', () => { + // 32 random bytes base64url-encoded -> 43 chars, [A-Za-z0-9_-], no '='. + const verifier = generateCodeVerifier(); + expect(verifier).toMatch(/^[A-Za-z0-9_-]+$/); + expect(verifier).toHaveLength(43); + }); + + it('returns a different value on each call', () => { + expect(generateCodeVerifier()).not.toBe(generateCodeVerifier()); }); }); describe('generateCodeChallenge', () => { - it('just runs', () => { - generateCodeChallenge('dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'); + it('derives the S256 challenge from the RFC 7636 test vector', () => { + // RFC 7636 Appendix B known-answer pair. + expect(generateCodeChallenge('dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk')).toBe( + 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM' + ); + }); + + it('produces a URL-safe base64 challenge with no padding', () => { + const challenge = generateCodeChallenge(generateCodeVerifier()); + expect(challenge).toMatch(/^[A-Za-z0-9_-]+$/); }); }); diff --git a/tests/sharedOperations.test.ts b/tests/sharedOperations.test.ts index 9d8a453..91e2aab 100644 --- a/tests/sharedOperations.test.ts +++ b/tests/sharedOperations.test.ts @@ -4,9 +4,15 @@ import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { EncryptedStorage } from '../src/encryptedStorage.js'; import { ApiCredentialStore } from '../src/apiCredentials/store.js'; -import { ApiCredentialStatus } from '../src/apiCredentials/base.js'; +import { ApiCredentialStatus, OAuthCredentials } from '../src/apiCredentials/base.js'; import { SlackApiCredentials } from '../src/services/slack.js'; -import { NoCurlCredentialsNotSupportedError, Service } from '../src/services/core/base.js'; +import { + NoCurlCredentialsNotSupportedError, + PrepareInputInvalidError, + PrepareNotSupportedError, + Service, +} from '../src/services/core/base.js'; +import { GOOGLE_GMAIL } from '../src/services/google/gmail.js'; import { RegisteredService } from '../src/services/core/registered.js'; import { ServiceRegistry } from '../src/serviceRegistry.js'; import { Config } from '../src/config.js'; @@ -16,6 +22,7 @@ import { authList, authBrowser, authBrowserPrepare, + prepareService, UnknownServiceError, PreparationRequiredError, } from '../src/sharedOperations.js'; @@ -326,4 +333,115 @@ describe('operations', () => { expect(result.alreadyPrepared).toBe(true); }); }); + + describe('prepareService', () => { + it('stores token-less OAuth credentials for a Google service and returns the result', () => { + const registry = new ServiceRegistry([GOOGLE_GMAIL]); + const store = createApiCredentialStore(); + + const result = prepareService( + registry, + store, + 'google-gmail', + JSON.stringify({ clientId: 'cid', clientSecret: 'csecret' }) + ); + + expect(result).toEqual({ serviceName: 'google-gmail', credentialType: 'oauth' }); + const stored = store.get('google-gmail'); + expect(stored).toBeInstanceOf(OAuthCredentials); + const oauth = stored as OAuthCredentials; + expect(oauth.clientId).toBe('cid'); + expect(oauth.clientSecret).toBe('csecret'); + expect(oauth.accessToken).toBeUndefined(); + expect(oauth.refreshToken).toBeUndefined(); + }); + + it('overwrites existing credentials unconditionally', () => { + const registry = new ServiceRegistry([GOOGLE_GMAIL]); + const store = createApiCredentialStore({ + 'google-gmail': { objectType: 'oauth', clientId: 'old-id', clientSecret: 'old-secret' }, + }); + + prepareService( + registry, + store, + 'google-gmail', + JSON.stringify({ clientId: 'new-id', clientSecret: 'new-secret' }) + ); + + expect((store.get('google-gmail') as OAuthCredentials).clientId).toBe('new-id'); + expect((store.get('google-gmail') as OAuthCredentials).clientSecret).toBe('new-secret'); + }); + + it('throws UnknownServiceError for an unknown service', () => { + const registry = new ServiceRegistry([GOOGLE_GMAIL]); + const store = createApiCredentialStore(); + + expect(() => prepareService(registry, store, 'nope', '{}')).toThrow(UnknownServiceError); + }); + + it('throws PrepareNotSupportedError for a service without a prepare schema, storing nothing', () => { + const registry = new ServiceRegistry([createMockService({ name: 'slack' })]); + const store = createApiCredentialStore(); + + expect(() => + prepareService( + registry, + store, + 'slack', + JSON.stringify({ clientId: 'a', clientSecret: 'b' }) + ) + ).toThrow(PrepareNotSupportedError); + expect(store.get('slack')).toBeNull(); + }); + + it('rejects malformed JSON without storing anything', () => { + const registry = new ServiceRegistry([GOOGLE_GMAIL]); + const store = createApiCredentialStore(); + + expect(() => prepareService(registry, store, 'google-gmail', '{not valid')).toThrow( + PrepareInputInvalidError + ); + expect(store.get('google-gmail')).toBeNull(); + }); + + it('rejects input missing required fields without storing anything', () => { + const registry = new ServiceRegistry([GOOGLE_GMAIL]); + const store = createApiCredentialStore(); + + expect(() => + prepareService(registry, store, 'google-gmail', JSON.stringify({ clientId: 'only-id' })) + ).toThrow(PrepareInputInvalidError); + expect(store.get('google-gmail')).toBeNull(); + }); + + it('rejects unknown keys (strict schema)', () => { + const registry = new ServiceRegistry([GOOGLE_GMAIL]); + const store = createApiCredentialStore(); + + expect(() => + prepareService( + registry, + store, + 'google-gmail', + JSON.stringify({ clientId: 'a', clientSecret: 'b', extra: 'nope' }) + ) + ).toThrow(PrepareInputInvalidError); + expect(store.get('google-gmail')).toBeNull(); + }); + + it('rejects empty string fields', () => { + const registry = new ServiceRegistry([GOOGLE_GMAIL]); + const store = createApiCredentialStore(); + + expect(() => + prepareService( + registry, + store, + 'google-gmail', + JSON.stringify({ clientId: '', clientSecret: 'b' }) + ) + ).toThrow(PrepareInputInvalidError); + }); + }); }); From 367bf2b16b4bc39b455fe1c593e5deb3c1aff34a Mon Sep 17 00:00:00 2001 From: Preston Seay Date: Mon, 22 Jun 2026 11:57:09 -0700 Subject: [PATCH 02/11] Format prepare command description with prettier Wrap the long `.description()` line in the new `prepare` command to satisfy prettier. Co-authored-by: Sculptor Co-Authored-By: Claude Opus 4.8 --- src/cliCommands.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/cliCommands.ts b/src/cliCommands.ts index 20a7fd2..5749ad2 100644 --- a/src/cliCommands.ts +++ b/src/cliCommands.ts @@ -739,7 +739,9 @@ export function registerCommands(program: Command, deps: CliDependencies): void program .command('prepare') - .description("Store a service's credentials from a JSON payload (e.g. an OAuth client id/secret).") + .description( + "Store a service's credentials from a JSON payload (e.g. an OAuth client id/secret)." + ) .argument('', 'Name of the service to prepare') .argument( '', From 051ed222c15b531ebfcd470ec6325236704f8355 Mon Sep 17 00:00:00 2001 From: Preston Seay Date: Mon, 22 Jun 2026 13:37:39 -0700 Subject: [PATCH 03/11] Keep auth browser-prepare as the default in Google service info Revert the per-service `info` strings to recommend `auth browser-prepare` (the default OAuth-client setup) rather than `latchkey prepare`. Reword the README so `prepare` is documented as an alternative without demoting `browser-prepare`. Co-authored-by: Sculptor Co-Authored-By: Claude Opus 4.8 --- README.md | 3 +-- src/services/google/analytics.ts | 3 +-- src/services/google/calendar.ts | 3 +-- src/services/google/docs.ts | 3 +-- src/services/google/drive.ts | 3 +-- src/services/google/gmail.ts | 3 +-- src/services/google/people.ts | 3 +-- src/services/google/sheets.ts | 3 +-- 8 files changed, 8 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index c3e1111..03bc838 100644 --- a/README.md +++ b/README.md @@ -39,8 +39,7 @@ Latchkey is a command-line tool that injects credentials into curl commands. - Only some services support this option. - `latchkey prepare ` - Store a service's credentials directly from a validated JSON payload (e.g. an OAuth `{"clientId":"...","clientSecret":"..."}`). - - For Google services this is the recommended way to register the official Imbue OAuth client, skipping the browser-based client setup. Run `latchkey auth browser ` afterward to complete login. - - The older `latchkey auth browser-prepare ` (which creates your own OAuth client via the Google Cloud Console) remains available as a fallback. + - For Google services, `latchkey auth browser-prepare ` remains the default way to set up an OAuth client; `prepare` is an alternative for supplying an existing client's id/secret directly. Run `latchkey auth browser ` afterward to complete login. Latchkey is primarily designed for AI agents. By invoking Latchkey, agents can utilize user-provided credentials or prompt diff --git a/src/services/google/analytics.ts b/src/services/google/analytics.ts index 2d49c5e..3046d49 100644 --- a/src/services/google/analytics.ts +++ b/src/services/google/analytics.ts @@ -14,8 +14,7 @@ export class GoogleAnalytics extends GoogleService { ] as const; readonly info = 'https://developers.google.com/analytics/devguides/reporting/data/v1. ' + - 'To authenticate, run "latchkey prepare google-analytics" with the official OAuth client id/secret (recommended), ' + - 'or "latchkey auth browser-prepare google-analytics" to create your own client first. ' + + 'If needed, run "latchkey auth browser-prepare google-analytics" to create an OAuth client first. ' + 'It may take a few minutes before the OAuth client is ready to use.'; readonly credentialCheckCurlArguments = [ diff --git a/src/services/google/calendar.ts b/src/services/google/calendar.ts index 6ce5873..7440769 100644 --- a/src/services/google/calendar.ts +++ b/src/services/google/calendar.ts @@ -14,8 +14,7 @@ export class GoogleCalendar extends GoogleService { readonly baseApiUrls = ['https://www.googleapis.com/calendar/'] as const; readonly info = 'https://developers.google.com/calendar/api/v3/reference. ' + - 'To authenticate, run "latchkey prepare google-calendar" with the official OAuth client id/secret (recommended), ' + - 'or "latchkey auth browser-prepare google-calendar" to create your own client first. ' + + 'If needed, run "latchkey auth browser-prepare google-calendar" to create an OAuth client first. ' + 'It may take a few minutes before the OAuth client is ready to use.'; readonly credentialCheckCurlArguments = [ diff --git a/src/services/google/docs.ts b/src/services/google/docs.ts index 28a5a3f..6d481d0 100644 --- a/src/services/google/docs.ts +++ b/src/services/google/docs.ts @@ -14,8 +14,7 @@ export class GoogleDocs extends GoogleService { readonly baseApiUrls = ['https://docs.googleapis.com/'] as const; readonly info = 'https://developers.google.com/docs/api/reference/rest. ' + - 'To authenticate, run "latchkey prepare google-docs" with the official OAuth client id/secret (recommended), ' + - 'or "latchkey auth browser-prepare google-docs" to create your own client first. ' + + 'If needed, run "latchkey auth browser-prepare google-docs" to create an OAuth client first. ' + 'It may take a few minutes before the OAuth client is ready to use.'; readonly credentialCheckCurlArguments = [ diff --git a/src/services/google/drive.ts b/src/services/google/drive.ts index 1be5849..b2ed242 100644 --- a/src/services/google/drive.ts +++ b/src/services/google/drive.ts @@ -11,8 +11,7 @@ export class GoogleDrive extends GoogleService { readonly baseApiUrls = ['https://www.googleapis.com/drive/'] as const; readonly info = 'https://developers.google.com/drive/api/reference/rest/v3. ' + - 'To authenticate, run "latchkey prepare google-drive" with the official OAuth client id/secret (recommended), ' + - 'or "latchkey auth browser-prepare google-drive" to create your own client first. ' + + 'If needed, run "latchkey auth browser-prepare google-drive" to create an OAuth client first. ' + 'It may take a few minutes before the OAuth client is ready to use.'; readonly credentialCheckCurlArguments = [ diff --git a/src/services/google/gmail.ts b/src/services/google/gmail.ts index 113c71a..261915c 100644 --- a/src/services/google/gmail.ts +++ b/src/services/google/gmail.ts @@ -16,8 +16,7 @@ export class GoogleGmail extends GoogleService { readonly baseApiUrls = ['https://gmail.googleapis.com/'] as const; readonly info = 'https://developers.google.com/gmail/api/reference/rest. ' + - 'To authenticate, run "latchkey prepare google-gmail" with the official OAuth client id/secret (recommended), ' + - 'or "latchkey auth browser-prepare google-gmail" to create your own client first. ' + + 'If needed, run "latchkey auth browser-prepare google-gmail" to create an OAuth client first. ' + 'It may take a few minutes before the OAuth client is ready to use.'; readonly credentialCheckCurlArguments = [ diff --git a/src/services/google/people.ts b/src/services/google/people.ts index 39046d2..87659a3 100644 --- a/src/services/google/people.ts +++ b/src/services/google/people.ts @@ -14,8 +14,7 @@ export class GooglePeople extends GoogleService { readonly baseApiUrls = ['https://people.googleapis.com/'] as const; readonly info = 'https://developers.google.com/people/api/rest. ' + - 'To authenticate, run "latchkey prepare google-people" with the official OAuth client id/secret (recommended), ' + - 'or "latchkey auth browser-prepare google-people" to create your own client first. ' + + 'If needed, run "latchkey auth browser-prepare google-people" to create an OAuth client first. ' + 'It may take a few minutes before the OAuth client is ready to use.'; readonly credentialCheckCurlArguments = [ diff --git a/src/services/google/sheets.ts b/src/services/google/sheets.ts index 736eeed..64e0faf 100644 --- a/src/services/google/sheets.ts +++ b/src/services/google/sheets.ts @@ -14,8 +14,7 @@ export class GoogleSheets extends GoogleService { readonly baseApiUrls = ['https://sheets.googleapis.com/'] as const; readonly info = 'https://developers.google.com/sheets/api/reference/rest. ' + - 'To authenticate, run "latchkey prepare google-sheets" with the official OAuth client id/secret (recommended), ' + - 'or "latchkey auth browser-prepare google-sheets" to create your own client first. ' + + 'If needed, run "latchkey auth browser-prepare google-sheets" to create an OAuth client first. ' + 'It may take a few minutes before the OAuth client is ready to use.'; readonly credentialCheckCurlArguments = [ From 626370b4b40476daa35785d305a94eb11e0ab258 Mon Sep 17 00:00:00 2001 From: Hynek Urban Date: Tue, 23 Jun 2026 09:39:19 +0200 Subject: [PATCH 04/11] Use "Done" for consistency with browser-prepare. --- src/cliCommands.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/cliCommands.ts b/src/cliCommands.ts index 5749ad2..5cbc167 100644 --- a/src/cliCommands.ts +++ b/src/cliCommands.ts @@ -757,7 +757,7 @@ export function registerCommands(program: Command, deps: CliDependencies): void command: 'prepare', params: { serviceName, json }, }); - deps.log(`Prepared ${serviceName}.`); + deps.log(`Done`); return; } try { @@ -767,7 +767,7 @@ export function registerCommands(program: Command, deps: CliDependencies): void encryptedStorage ); const result = prepareService(deps.registry, apiCredentialStore, serviceName, json); - deps.log(`Prepared ${result.serviceName}.`); + deps.log(`Done`); } catch (error) { if ( error instanceof UnknownServiceError || From eb4d3cad740ef5e507d920c92d4a3bb2491b902b Mon Sep 17 00:00:00 2001 From: Hynek Urban Date: Tue, 23 Jun 2026 09:49:52 +0200 Subject: [PATCH 05/11] Don't mention `latchkey prepare` in the prime space of the README. --- README.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/README.md b/README.md index 03bc838..867f330 100644 --- a/README.md +++ b/README.md @@ -37,9 +37,6 @@ Latchkey is a command-line tool that injects credentials into curl commands. - Open a browser login pop-up window and store the resulting API credentials. - This also allows agents to prompt users for credentials. - Only some services support this option. -- `latchkey prepare ` - - Store a service's credentials directly from a validated JSON payload (e.g. an OAuth `{"clientId":"...","clientSecret":"..."}`). - - For Google services, `latchkey auth browser-prepare ` remains the default way to set up an OAuth client; `prepare` is an alternative for supplying an existing client's id/secret directly. Run `latchkey auth browser ` afterward to complete login. Latchkey is primarily designed for AI agents. By invoking Latchkey, agents can utilize user-provided credentials or prompt From 2b45bccc71e39a5ea9a7f9e11782a61682d8d38b Mon Sep 17 00:00:00 2001 From: Hynek Urban Date: Tue, 23 Jun 2026 09:57:47 +0200 Subject: [PATCH 06/11] Change `prepare` to `auth prepare` for consistency. --- src/cliCommands.ts | 8 ++++---- src/gateway/latchkeyEndpoint.ts | 4 ++-- src/services/core/base.ts | 8 ++++---- src/services/google/base.ts | 4 ++-- src/sharedOperations.ts | 2 +- tests/cli.test.ts | 20 ++++++++++---------- tests/latchkeyEndpoint.test.ts | 12 ++++++------ 7 files changed, 29 insertions(+), 29 deletions(-) diff --git a/src/cliCommands.ts b/src/cliCommands.ts index 5cbc167..fab0f08 100644 --- a/src/cliCommands.ts +++ b/src/cliCommands.ts @@ -737,7 +737,7 @@ export function registerCommands(program: Command, deps: CliDependencies): void } }); - program + authCommand .command('prepare') .description( "Store a service's credentials from a JSON payload (e.g. an OAuth client id/secret)." @@ -749,12 +749,12 @@ export function registerCommands(program: Command, deps: CliDependencies): void ) .addHelpText( 'after', - `\nExample:\n $ latchkey prepare google-gmail '{"clientId":"","clientSecret":""}'` + `\nExample:\n $ latchkey auth prepare google-gmail '{"clientId":"","clientSecret":""}'` ) .action(async (serviceName: string, json: string) => { if (deps.config.gatewayUrl !== null) { await forwardToGateway(deps, { - command: 'prepare', + command: 'auth prepare', params: { serviceName, json }, }); deps.log(`Done`); @@ -766,7 +766,7 @@ export function registerCommands(program: Command, deps: CliDependencies): void deps.config.credentialStorePath, encryptedStorage ); - const result = prepareService(deps.registry, apiCredentialStore, serviceName, json); + prepareService(deps.registry, apiCredentialStore, serviceName, json); deps.log(`Done`); } catch (error) { if ( diff --git a/src/gateway/latchkeyEndpoint.ts b/src/gateway/latchkeyEndpoint.ts index 341ea5d..a6c50d1 100644 --- a/src/gateway/latchkeyEndpoint.ts +++ b/src/gateway/latchkeyEndpoint.ts @@ -75,7 +75,7 @@ const AuthBrowserPrepareRequestSchema = z.object({ }); const PrepareRequestSchema = z.object({ - command: z.literal('prepare'), + command: z.literal('auth prepare'), params: serviceNameParams.extend({ json: z.string(), }), @@ -175,7 +175,7 @@ async function dispatch( parsed.params.serviceName ); - case 'prepare': + case 'auth prepare': return prepareService( deps.registry, apiCredentialStore, diff --git a/src/services/core/base.ts b/src/services/core/base.ts index e7f7481..d7330f8 100644 --- a/src/services/core/base.ts +++ b/src/services/core/base.ts @@ -40,13 +40,13 @@ export class LoginFailedError extends Error { } /** - * Thrown when `latchkey prepare` is run for a service that does not declare a + * Thrown when `latchkey auth prepare` is run for a service that does not declare a * prepare schema (the base default — services opt in by setting one). */ export class PrepareNotSupportedError extends Error { constructor(serviceName: string) { super( - `Service '${serviceName}' does not support 'latchkey prepare'. ` + + `Service '${serviceName}' does not support 'latchkey auth prepare'. ` + `Use 'latchkey services info ${serviceName}' to see how to authenticate.` ); this.name = 'PrepareNotSupportedError'; @@ -54,7 +54,7 @@ export class PrepareNotSupportedError extends Error { } /** - * Thrown when the JSON passed to `latchkey prepare` is malformed or does not + * Thrown when the JSON passed to `latchkey auth prepare` is malformed or does not * match the service's prepare schema. The whole command is rejected and * nothing is stored. */ @@ -180,7 +180,7 @@ export abstract class Service { } /** - * Build credentials from a parsed JSON payload for `latchkey prepare`. + * Build credentials from a parsed JSON payload for `latchkey auth prepare`. * * Optional, like `getSession`/`refreshCredentials`: services opt in by * implementing it (typically via `buildPreparedCredentials` with a Zod diff --git a/src/services/google/base.ts b/src/services/google/base.ts index 0973d0a..d10ffb5 100644 --- a/src/services/google/base.ts +++ b/src/services/google/base.ts @@ -824,7 +824,7 @@ class GoogleServiceSession extends BrowserFollowupServiceSession { } /** - * JSON accepted by `latchkey prepare `: the OAuth client + * JSON accepted by `latchkey auth prepare `: the OAuth client * credentials to use for that service. `.strict()` rejects unknown keys so * typos are reported instead of silently ignored. */ @@ -851,7 +851,7 @@ export abstract class GoogleService extends Service { /** * Google services accept an official OAuth client's id/secret via - * `latchkey prepare`, stored as token-less OAuth credentials until login. + * `latchkey auth prepare`, stored as token-less OAuth credentials until login. */ override prepareFromJson(parsedJson: unknown): ApiCredentials { return buildPreparedCredentials( diff --git a/src/sharedOperations.ts b/src/sharedOperations.ts index 5eb37da..f2f4361 100644 --- a/src/sharedOperations.ts +++ b/src/sharedOperations.ts @@ -265,7 +265,7 @@ export interface PrepareServiceResult { /** * Store credentials for a service from a validated JSON payload - * (`latchkey prepare `). The whole operation is rejected — and + * (`latchkey auth prepare `). The whole operation is rejected — and * nothing is stored — if the JSON is malformed, fails the service's schema, or * the service does not support prepare. */ diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 19e5db1..76e47f2 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -1205,11 +1205,11 @@ describe('CLI commands with dependency injection', () => { }); await runCommand( - ['prepare', 'google-gmail', '{"clientId":"cid","clientSecret":"csecret"}'], + ['auth', 'prepare', 'google-gmail', '{"clientId":"cid","clientSecret":"csecret"}'], deps ); - expect(logs).toContain('Prepared google-gmail.'); + expect(logs).toContain('Done'); const storedData = JSON.parse(readSecureFile(storePath) ?? '{}') as Record; expect(storedData['google-gmail']).toEqual({ objectType: 'oauth', @@ -1221,7 +1221,7 @@ describe('CLI commands with dependency injection', () => { it('returns an error for an unknown service', async () => { const deps = createMockDependencies(); - await runCommand(['prepare', 'unknown-service', '{}'], deps); + await runCommand(['auth', 'prepare', 'unknown-service', '{}'], deps); expect(exitCode).toBe(1); }); @@ -1229,7 +1229,7 @@ describe('CLI commands with dependency injection', () => { it('returns an error for a service that does not support prepare', async () => { const deps = createMockDependencies(); - await runCommand(['prepare', 'slack', '{"clientId":"a","clientSecret":"b"}'], deps); + await runCommand(['auth', 'prepare', 'slack', '{"clientId":"a","clientSecret":"b"}'], deps); expect(exitCode).toBe(1); }); @@ -1242,7 +1242,7 @@ describe('CLI commands with dependency injection', () => { registry: new ServiceRegistry([GOOGLE_GMAIL]), }); - await runCommand(['prepare', 'google-gmail', '{not valid'], deps); + await runCommand(['auth', 'prepare', 'google-gmail', '{not valid'], deps); expect(exitCode).toBe(1); const storedData = JSON.parse(readSecureFile(storePath) ?? '{}') as Record; @@ -1257,7 +1257,7 @@ describe('CLI commands with dependency injection', () => { registry: new ServiceRegistry([GOOGLE_GMAIL]), }); - await runCommand(['prepare', 'google-gmail', '{"clientId":"only-id"}'], deps); + await runCommand(['auth', 'prepare', 'google-gmail', '{"clientId":"only-id"}'], deps); expect(exitCode).toBe(1); const storedData = JSON.parse(readSecureFile(storePath) ?? '{}') as Record; @@ -2539,7 +2539,7 @@ describe('CLI commands with dependency injection', () => { }); await runCommand( - ['prepare', 'google-gmail', '{"clientId":"cid","clientSecret":"csecret"}'], + ['auth', 'prepare', 'google-gmail', '{"clientId":"cid","clientSecret":"csecret"}'], deps ); @@ -2547,13 +2547,13 @@ describe('CLI commands with dependency injection', () => { const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; expect(url).toBe(`${GATEWAY_URL}/latchkey`); expect(JSON.parse(init.body as string) as unknown).toEqual({ - command: 'prepare', + command: 'auth prepare', params: { serviceName: 'google-gmail', json: '{"clientId":"cid","clientSecret":"csecret"}', }, }); - expect(logs).toContain('Prepared google-gmail.'); + expect(logs).toContain('Done'); }); it.each([ @@ -2562,7 +2562,7 @@ describe('CLI commands with dependency injection', () => { ['auth list', ['auth', 'list']], ['auth browser', ['auth', 'browser', 'slack']], ['auth browser-prepare', ['auth', 'browser-prepare', 'slack']], - ['prepare', ['prepare', 'foo', '{}']], + ['auth prepare', ['auth', 'prepare', 'foo', '{}']], ])('reports gateway errors on stderr (not stdout) for `%s`', async (_name, argv) => { makeFetchMock( new Response(JSON.stringify({ error: 'Unknown service: foo.' }), { status: 400 }) diff --git a/tests/latchkeyEndpoint.test.ts b/tests/latchkeyEndpoint.test.ts index d0edff3..a4cc730 100644 --- a/tests/latchkeyEndpoint.test.ts +++ b/tests/latchkeyEndpoint.test.ts @@ -94,7 +94,7 @@ describe('LatchkeyRequestSchema', () => { it('should validate prepare with serviceName and json', () => { const result = LatchkeyRequestSchema.safeParse({ - command: 'prepare', + command: 'auth prepare', params: { serviceName: 'google-gmail', json: '{"clientId":"a","clientSecret":"b"}' }, }); expect(result.success).toBe(true); @@ -102,7 +102,7 @@ describe('LatchkeyRequestSchema', () => { it('should reject prepare without json', () => { const result = LatchkeyRequestSchema.safeParse({ - command: 'prepare', + command: 'auth prepare', params: { serviceName: 'google-gmail' }, }); expect(result.success).toBe(false); @@ -110,7 +110,7 @@ describe('LatchkeyRequestSchema', () => { it('should reject prepare without serviceName', () => { const result = LatchkeyRequestSchema.safeParse({ - command: 'prepare', + command: 'auth prepare', params: { json: '{}' }, }); expect(result.success).toBe(false); @@ -348,7 +348,7 @@ describe('/latchkey/ endpoint', () => { it('stores OAuth client credentials for a Google service', async () => { gateway = await createTestGateway({}, { registry: new ServiceRegistry([GOOGLE_GMAIL]) }); const response = await postLatchkey({ - command: 'prepare', + command: 'auth prepare', params: { serviceName: 'google-gmail', json: '{"clientId":"cid","clientSecret":"csecret"}', @@ -365,7 +365,7 @@ describe('/latchkey/ endpoint', () => { it('returns 400 when the service does not support prepare', async () => { gateway = await createTestGateway(); const response = await postLatchkey({ - command: 'prepare', + command: 'auth prepare', params: { serviceName: 'slack', json: '{"clientId":"a","clientSecret":"b"}' }, }); @@ -377,7 +377,7 @@ describe('/latchkey/ endpoint', () => { it('returns 400 for malformed prepare JSON', async () => { gateway = await createTestGateway({}, { registry: new ServiceRegistry([GOOGLE_GMAIL]) }); const response = await postLatchkey({ - command: 'prepare', + command: 'auth prepare', params: { serviceName: 'google-gmail', json: '{not valid' }, }); From 7c8b89f9c71f178e18b9d397adcf0980ed028c90 Mon Sep 17 00:00:00 2001 From: Hynek Urban Date: Tue, 23 Jun 2026 10:04:42 +0200 Subject: [PATCH 07/11] Don't mention "official clients". --- src/services/google/base.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/services/google/base.ts b/src/services/google/base.ts index d10ffb5..80b663f 100644 --- a/src/services/google/base.ts +++ b/src/services/google/base.ts @@ -778,8 +778,7 @@ class GoogleServiceSession extends BrowserFollowupServiceSession { // PKCE (RFC 7636): bind the authorization code to a one-time verifier so a // stolen code cannot be redeemed without it. We keep sending the client - // secret too — the official client is a Google "Desktop app" client, so - // this is confidential-client + PKCE, defense-in-depth. + // secret too so this is confidential-client + PKCE, defense-in-depth. const codeVerifier = generateCodeVerifier(); const codeChallenge = generateCodeChallenge(codeVerifier); @@ -850,8 +849,8 @@ export abstract class GoogleService extends Service { protected abstract readonly config: GoogleServiceConfig; /** - * Google services accept an official OAuth client's id/secret via - * `latchkey auth prepare`, stored as token-less OAuth credentials until login. + * Google services accept an OAuth client's id/secret prepared + * in advance via `latchkey auth prepare`, stored as token-less OAuth credentials until login. */ override prepareFromJson(parsedJson: unknown): ApiCredentials { return buildPreparedCredentials( From eddaf26f8af9806a1063dc8ec0265b367ae08d4e Mon Sep 17 00:00:00 2001 From: Hynek Urban Date: Tue, 23 Jun 2026 12:23:12 +0200 Subject: [PATCH 08/11] Add `auth prepare` support to notion-mcp, too. --- src/services/notion-mcp.ts | 30 ++++++++++++++++++++++ tests/sharedOperations.test.ts | 47 ++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+) diff --git a/src/services/notion-mcp.ts b/src/services/notion-mcp.ts index c712b24..89069ee 100644 --- a/src/services/notion-mcp.ts +++ b/src/services/notion-mcp.ts @@ -5,6 +5,7 @@ * This is separate from the existing Notion service which uses internal integration tokens. */ +import { z } from 'zod'; import type { Browser, BrowserContext, Response } from 'playwright'; import { type ApiCredentials, OAuthCredentials } from '../apiCredentials/base.js'; import { runCaptured } from '../curl.js'; @@ -20,9 +21,24 @@ import { ServiceSession, LoginFailedError, LoginCancelledError, + buildPreparedCredentials, isBrowserClosedError, } from './core/base.js'; +/** + * JSON accepted by `latchkey auth prepare notion-mcp`: the OAuth client id to + * reuse instead of registering a new client dynamically at mcp.notion.com. + * Notion MCP is a public client, so no secret is needed. `.strict()` rejects + * unknown keys so typos are reported instead of silently ignored. + */ +export const NotionMcpPrepareInputSchema = z + .object({ + clientId: z.string().min(1), + }) + .strict(); + +export type NotionMcpPrepareInput = z.infer; + const TOKEN_ENDPOINT = 'https://mcp.notion.com/token'; const REGISTRATION_ENDPOINT = 'https://mcp.notion.com/register'; const AUTHORIZATION_ENDPOINT = 'https://mcp.notion.com/authorize'; @@ -204,6 +220,20 @@ export class NotionMcp extends Service { return `latchkey auth set ${serviceName} -H "Authorization: Bearer "`; } + /** + * Notion MCP accepts an OAuth client id prepared in advance via + * `latchkey auth prepare`, stored as token-less OAuth credentials until login. + * The login flow reuses this client id instead of registering a new client. + */ + override prepareFromJson(parsedJson: unknown): ApiCredentials { + return buildPreparedCredentials( + this.name, + NotionMcpPrepareInputSchema, + parsedJson, + ({ clientId }) => new OAuthCredentials(clientId, '') + ); + } + override getSession(appNamePrefix: string): NotionMcpSession { return new NotionMcpSession(this, appNamePrefix); } diff --git a/tests/sharedOperations.test.ts b/tests/sharedOperations.test.ts index 91e2aab..b23df52 100644 --- a/tests/sharedOperations.test.ts +++ b/tests/sharedOperations.test.ts @@ -13,6 +13,7 @@ import { Service, } from '../src/services/core/base.js'; import { GOOGLE_GMAIL } from '../src/services/google/gmail.js'; +import { NOTION_MCP } from '../src/services/notion-mcp.js'; import { RegisteredService } from '../src/services/core/registered.js'; import { ServiceRegistry } from '../src/serviceRegistry.js'; import { Config } from '../src/config.js'; @@ -443,5 +444,51 @@ describe('operations', () => { ) ).toThrow(PrepareInputInvalidError); }); + + it('stores a token-less OAuth client id for notion-mcp (public client, no secret)', () => { + const registry = new ServiceRegistry([NOTION_MCP]); + const store = createApiCredentialStore(); + + const result = prepareService( + registry, + store, + 'notion-mcp', + JSON.stringify({ clientId: 'notion-client-id' }) + ); + + expect(result).toEqual({ serviceName: 'notion-mcp', credentialType: 'oauth' }); + const stored = store.get('notion-mcp'); + expect(stored).toBeInstanceOf(OAuthCredentials); + const oauth = stored as OAuthCredentials; + expect(oauth.clientId).toBe('notion-client-id'); + expect(oauth.clientSecret).toBe(''); + expect(oauth.accessToken).toBeUndefined(); + expect(oauth.refreshToken).toBeUndefined(); + }); + + it('rejects a notion-mcp clientSecret (unknown key, strict schema)', () => { + const registry = new ServiceRegistry([NOTION_MCP]); + const store = createApiCredentialStore(); + + expect(() => + prepareService( + registry, + store, + 'notion-mcp', + JSON.stringify({ clientId: 'a', clientSecret: 'b' }) + ) + ).toThrow(PrepareInputInvalidError); + expect(store.get('notion-mcp')).toBeNull(); + }); + + it('rejects notion-mcp input missing clientId', () => { + const registry = new ServiceRegistry([NOTION_MCP]); + const store = createApiCredentialStore(); + + expect(() => prepareService(registry, store, 'notion-mcp', '{}')).toThrow( + PrepareInputInvalidError + ); + expect(store.get('notion-mcp')).toBeNull(); + }); }); }); From 86cd21d1f534ba4efc45c0492e5e4b950a12e099 Mon Sep 17 00:00:00 2001 From: Hynek Urban Date: Tue, 23 Jun 2026 12:35:16 +0200 Subject: [PATCH 09/11] Improve the help string. --- src/cliCommands.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/cliCommands.ts b/src/cliCommands.ts index fab0f08..eaea77e 100644 --- a/src/cliCommands.ts +++ b/src/cliCommands.ts @@ -740,12 +740,12 @@ export function registerCommands(program: Command, deps: CliDependencies): void authCommand .command('prepare') .description( - "Store a service's credentials from a JSON payload (e.g. an OAuth client id/secret)." + "Register a service's client details (e.g. an OAuth client id/secret) from a JSON payload, for use during login." ) .argument('', 'Name of the service to prepare') .argument( '', - 'Service-specific credential JSON, e.g. \'{"clientId":"...","clientSecret":"..."}\'' + 'Service-specific registration JSON, e.g. \'{"clientId":"...","clientSecret":"..."}\'' ) .addHelpText( 'after', From d1473dc8f738fa378a32ef481dcd3c5d3a6a7fcf Mon Sep 17 00:00:00 2001 From: Hynek Urban Date: Tue, 23 Jun 2026 12:49:31 +0200 Subject: [PATCH 10/11] Fix an edge case in google's `auth browser` command. --- src/services/core/base.ts | 16 ++++++++++++++++ src/services/google/base.ts | 12 +++++++++--- tests/serviceErrors.test.ts | 31 +++++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 3 deletions(-) create mode 100644 tests/serviceErrors.test.ts diff --git a/src/services/core/base.ts b/src/services/core/base.ts index d7330f8..13d1cca 100644 --- a/src/services/core/base.ts +++ b/src/services/core/base.ts @@ -103,6 +103,22 @@ export function isBrowserClosedError(error: Error): boolean { ); } +/** + * Detects the Playwright/CDP error raised when a response body can no longer be + * retrieved (`Network.getResponseBody` reports "No resource with given + * identifier found"). This happens for responses that retain no readable body — + * redirects, evicted or cached resources, or bodies fetched after the page has + * navigated onward. Callers that read response bodies opportunistically should + * treat this as inconclusive rather than fatal. + */ +export function isResponseBodyUnavailableError(error: Error): boolean { + const message = error.message.toLowerCase(); + return ( + message.includes('no resource with given identifier') || + message.includes('network.getresponsebody') + ); +} + export function isTimeoutError(error: Error): boolean { return error.name === 'TimeoutError'; } diff --git a/src/services/google/base.ts b/src/services/google/base.ts index 80b663f..26f344a 100644 --- a/src/services/google/base.ts +++ b/src/services/google/base.ts @@ -32,6 +32,7 @@ import { LoginFailedError, LoginCancelledError, isBrowserClosedError, + isResponseBodyUnavailableError, isTimeoutError, } from '../core/base.js'; import type { EncryptedStorage } from '../../encryptedStorage.js'; @@ -530,9 +531,14 @@ function checkGoogleLoginResponse( .catch((error: unknown) => { // The response body can become unreadable if the page/context // closes while it's still being read (e.g. the automation - // navigates onward, or the user closes the browser). Treat that - // specific race as inconclusive; let any other error propagate. - if (error instanceof Error && isBrowserClosedError(error)) { + // navigates onward, or the user closes the browser), or if the + // response simply retains no readable body (redirects, cached or + // evicted resources). Login detection here is best-effort, so treat + // those cases as inconclusive; let any other error propagate. + if ( + error instanceof Error && + (isBrowserClosedError(error) || isResponseBodyUnavailableError(error)) + ) { return; } throw error; diff --git a/tests/serviceErrors.test.ts b/tests/serviceErrors.test.ts new file mode 100644 index 0000000..6a9e8c5 --- /dev/null +++ b/tests/serviceErrors.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from 'vitest'; +import { + isBrowserClosedError, + isResponseBodyUnavailableError, +} from '../src/services/core/base.js'; + +describe('isResponseBodyUnavailableError', () => { + it('recognizes the CDP "no resource with given identifier" error', () => { + const error = new Error( + 'Protocol error (Network.getResponseBody): No resource with given identifier found' + ); + expect(isResponseBodyUnavailableError(error)).toBe(true); + }); + + it('matches case-insensitively', () => { + expect( + isResponseBodyUnavailableError(new Error('NO RESOURCE WITH GIVEN IDENTIFIER FOUND')) + ).toBe(true); + }); + + it('does not match unrelated errors', () => { + expect(isResponseBodyUnavailableError(new Error('something else went wrong'))).toBe(false); + }); + + it('is distinct from browser-closed errors', () => { + const bodyError = new Error( + 'Protocol error (Network.getResponseBody): No resource with given identifier found' + ); + expect(isBrowserClosedError(bodyError)).toBe(false); + }); +}); From 09fd7bef30332bee9ef5263808e5b11db8cc04e2 Mon Sep 17 00:00:00 2001 From: Hynek Urban Date: Tue, 23 Jun 2026 12:58:55 +0200 Subject: [PATCH 11/11] Fix formatting. --- tests/serviceErrors.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/serviceErrors.test.ts b/tests/serviceErrors.test.ts index 6a9e8c5..cec277b 100644 --- a/tests/serviceErrors.test.ts +++ b/tests/serviceErrors.test.ts @@ -1,8 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { - isBrowserClosedError, - isResponseBodyUnavailableError, -} from '../src/services/core/base.js'; +import { isBrowserClosedError, isResponseBodyUnavailableError } from '../src/services/core/base.js'; describe('isResponseBodyUnavailableError', () => { it('recognizes the CDP "no resource with given identifier" error', () => {