diff --git a/src/cliCommands.ts b/src/cliCommands.ts index 1afa152..eaea77e 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,50 @@ export function registerCommands(program: Command, deps: CliDependencies): void } }); + authCommand + .command('prepare') + .description( + "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 registration JSON, e.g. \'{"clientId":"...","clientSecret":"..."}\'' + ) + .addHelpText( + 'after', + `\nExample:\n $ latchkey auth prepare google-gmail '{"clientId":"","clientSecret":""}'` + ) + .action(async (serviceName: string, json: string) => { + if (deps.config.gatewayUrl !== null) { + await forwardToGateway(deps, { + command: 'auth prepare', + params: { serviceName, json }, + }); + deps.log(`Done`); + return; + } + try { + const encryptedStorage = await createEncryptedStorageFromConfig(deps.config); + const apiCredentialStore = new ApiCredentialStore( + deps.config.credentialStorePath, + encryptedStorage + ); + prepareService(deps.registry, apiCredentialStore, serviceName, json); + deps.log(`Done`); + } 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..a6c50d1 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('auth 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 'auth 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..13d1cca 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 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 auth prepare'. ` + + `Use 'latchkey services info ${serviceName}' to see how to authenticate.` + ); + this.name = 'PrepareNotSupportedError'; + } +} + +/** + * 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. + */ +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 ( @@ -50,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'; } @@ -126,6 +195,18 @@ export abstract class Service { throw new NoCurlCredentialsNotSupportedError(this.name); } + /** + * 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 + * 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/base.ts b/src/services/google/base.ts index 3e8fd18..26f344a 100644 --- a/src/services/google/base.ts +++ b/src/services/google/base.ts @@ -20,15 +20,19 @@ import { } from '../../playwrightUtils.js'; import { exchangeCodeForTokens, + generateCodeChallenge, + generateCodeVerifier, refreshAccessToken, startOAuthCallbackServer, } from '../../oauthUtils.js'; import { Service, BrowserFollowupServiceSession, + buildPreparedCredentials, LoginFailedError, LoginCancelledError, isBrowserClosedError, + isResponseBodyUnavailableError, isTimeoutError, } from '../core/base.js'; import type { EncryptedStorage } from '../../encryptedStorage.js'; @@ -527,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; @@ -773,6 +782,12 @@ 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 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 +795,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 +806,8 @@ class GoogleServiceSession extends BrowserFollowupServiceSession { code, clientId, clientSecret, - redirectUri + redirectUri, + codeVerifier ); const accessTokenExpiresAt = new Date(Date.now() + tokens.expires_in * 1000).toISOString(); @@ -810,6 +828,20 @@ class GoogleServiceSession extends BrowserFollowupServiceSession { } } +/** + * 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. + */ +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 +854,19 @@ export abstract class GoogleService extends Service { protected abstract readonly config: GoogleServiceConfig; + /** + * 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( + 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/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/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/src/sharedOperations.ts b/src/sharedOperations.ts index 27b8e66..f2f4361 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 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. + */ +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..76e47f2 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( + ['auth', 'prepare', 'google-gmail', '{"clientId":"cid","clientSecret":"csecret"}'], + deps + ); + + expect(logs).toContain('Done'); + 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(['auth', '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(['auth', '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(['auth', '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(['auth', '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( + ['auth', '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: 'auth prepare', + params: { + serviceName: 'google-gmail', + json: '{"clientId":"cid","clientSecret":"csecret"}', + }, + }); + expect(logs).toContain('Done'); + }); + 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']], + ['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 4d09ded..a4cc730 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: 'auth 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: 'auth prepare', + params: { serviceName: 'google-gmail' }, + }); + expect(result.success).toBe(false); + }); + + it('should reject prepare without serviceName', () => { + const result = LatchkeyRequestSchema.safeParse({ + command: 'auth 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: 'auth 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: 'auth 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: 'auth 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/serviceErrors.test.ts b/tests/serviceErrors.test.ts new file mode 100644 index 0000000..cec277b --- /dev/null +++ b/tests/serviceErrors.test.ts @@ -0,0 +1,28 @@ +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); + }); +}); diff --git a/tests/sharedOperations.test.ts b/tests/sharedOperations.test.ts index 9d8a453..b23df52 100644 --- a/tests/sharedOperations.test.ts +++ b/tests/sharedOperations.test.ts @@ -4,9 +4,16 @@ 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 { 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'; @@ -16,6 +23,7 @@ import { authList, authBrowser, authBrowserPrepare, + prepareService, UnknownServiceError, PreparationRequiredError, } from '../src/sharedOperations.js'; @@ -326,4 +334,161 @@ 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); + }); + + 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(); + }); + }); });