Skip to content
Merged
47 changes: 47 additions & 0 deletions src/cliCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ import {
LoginCancelledError,
LoginFailedError,
NoCurlCredentialsNotSupportedError,
PrepareInputInvalidError,
PrepareNotSupportedError,
Service,
} from './services/index.js';
import {
Expand Down Expand Up @@ -77,6 +79,7 @@ import {
authList,
authBrowser,
authBrowserPrepare,
prepareService,
UnknownServiceError,
BrowserNotConfiguredError,
PreparationRequiredError,
Expand Down Expand Up @@ -734,6 +737,50 @@ 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)."
)
.argument('<service_name>', 'Name of the service to prepare')
.argument(
'<json>',
'Service-specific credential JSON, e.g. \'{"clientId":"...","clientSecret":"..."}\''
)
.addHelpText(
'after',
`\nExample:\n $ latchkey auth prepare google-gmail '{"clientId":"<id>","clientSecret":"<secret>"}'`
)
.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.')
Expand Down
26 changes: 25 additions & 1 deletion src/gateway/latchkeyEndpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
authList,
authBrowser,
authBrowserPrepare,
prepareService,
UnknownServiceError,
BrowserNotConfiguredError,
PreparationRequiredError,
Expand All @@ -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(
Expand Down Expand Up @@ -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<typeof LatchkeyRequestSchema>;
Expand All @@ -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 {
Expand Down Expand Up @@ -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
);
}
}

Expand Down
65 changes: 65 additions & 0 deletions src/services/core/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*/

import type { Browser, BrowserContext, Page, Response } from 'playwright';
import type { z, ZodTypeAny } from 'zod';
import {
ApiCredentialStatus,
ApiCredentials,
Expand Down Expand Up @@ -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<Schema extends ZodTypeAny>(
serviceName: string,
schema: Schema,
parsedJson: unknown,
build: (validatedInput: z.infer<Schema>) => 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<Schema>);
}

export function isBrowserClosedError(error: Error): boolean {
const message = error.message.toLowerCase();
return (
Expand Down Expand Up @@ -126,6 +179,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.
Expand Down
41 changes: 40 additions & 1 deletion src/services/google/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,15 @@ import {
} from '../../playwrightUtils.js';
import {
exchangeCodeForTokens,
generateCodeChallenge,
generateCodeVerifier,
refreshAccessToken,
startOAuthCallbackServer,
} from '../../oauthUtils.js';
import {
Service,
BrowserFollowupServiceSession,
buildPreparedCredentials,
LoginFailedError,
LoginCancelledError,
isBrowserClosedError,
Expand Down Expand Up @@ -773,13 +776,21 @@ 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);
authUrl.searchParams.set('response_type', 'code');
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());

Expand All @@ -789,7 +800,8 @@ class GoogleServiceSession extends BrowserFollowupServiceSession {
code,
clientId,
clientSecret,
redirectUri
redirectUri,
codeVerifier
);
const accessTokenExpiresAt = new Date(Date.now() + tokens.expires_in * 1000).toISOString();

Expand All @@ -810,6 +822,20 @@ class GoogleServiceSession extends BrowserFollowupServiceSession {
}
}

/**
* JSON accepted by `latchkey auth prepare <google-service>`: 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<typeof GooglePrepareInputSchema>;

/**
* Abstract base class for individual Google API services.
*
Expand All @@ -822,6 +848,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.
*/

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Vet Issue commit_message_mismatch severity: 3/5, confidence: 0.80

The request explicitly states 'Updated every Google service's info string (surfaced via services info) to recommend prepare first, with browser-prepare as the fallback' and 'Updated the README command overview.' Neither the README nor any service info strings are modified in the diff, leaving the documentation portion of the request unimplemented.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Vet Issue documentation_implementation_mismatch severity: 3/5, confidence: 0.80

The user request states 'Updated every Google service's info string (surfaced via services info) to recommend prepare first, with browser-prepare as the fallback' and 'Updated the README command overview.' However, the diff contains no changes to any service's info string nor to the README. The documentation updates described in the request are missing.

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 <token>"`;
}
Expand Down
2 changes: 2 additions & 0 deletions src/services/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ export {
LoginCancelledError,
LoginFailedError,
NoCurlCredentialsNotSupportedError,
PrepareNotSupportedError,
PrepareInputInvalidError,
} from './core/base.js';
export { RegisteredService } from './core/registered.js';

Expand Down
30 changes: 30 additions & 0 deletions src/services/notion-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<typeof NotionMcpPrepareInputSchema>;

const TOKEN_ENDPOINT = 'https://mcp.notion.com/token';
const REGISTRATION_ENDPOINT = 'https://mcp.notion.com/register';
const AUTHORIZATION_ENDPOINT = 'https://mcp.notion.com/authorize';
Expand Down Expand Up @@ -204,6 +220,20 @@ export class NotionMcp extends Service {
return `latchkey auth set ${serviceName} -H "Authorization: Bearer <token>"`;
}

/**
* 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);
}
Expand Down
Loading
Loading