Skip to content

Commit d56d9be

Browse files
authored
Merge pull request #92 from imbue-ai/preston/google-oauth
Add `latchkey prepare` command and PKCE for Google OAuth
2 parents f83e537 + 09fd7be commit d56d9be

12 files changed

Lines changed: 666 additions & 12 deletions

src/cliCommands.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ import {
4646
LoginCancelledError,
4747
LoginFailedError,
4848
NoCurlCredentialsNotSupportedError,
49+
PrepareInputInvalidError,
50+
PrepareNotSupportedError,
4951
Service,
5052
} from './services/index.js';
5153
import {
@@ -77,6 +79,7 @@ import {
7779
authList,
7880
authBrowser,
7981
authBrowserPrepare,
82+
prepareService,
8083
UnknownServiceError,
8184
BrowserNotConfiguredError,
8285
PreparationRequiredError,
@@ -734,6 +737,50 @@ export function registerCommands(program: Command, deps: CliDependencies): void
734737
}
735738
});
736739

740+
authCommand
741+
.command('prepare')
742+
.description(
743+
"Register a service's client details (e.g. an OAuth client id/secret) from a JSON payload, for use during login."
744+
)
745+
.argument('<service_name>', 'Name of the service to prepare')
746+
.argument(
747+
'<json>',
748+
'Service-specific registration JSON, e.g. \'{"clientId":"...","clientSecret":"..."}\''
749+
)
750+
.addHelpText(
751+
'after',
752+
`\nExample:\n $ latchkey auth prepare google-gmail '{"clientId":"<id>","clientSecret":"<secret>"}'`
753+
)
754+
.action(async (serviceName: string, json: string) => {
755+
if (deps.config.gatewayUrl !== null) {
756+
await forwardToGateway(deps, {
757+
command: 'auth prepare',
758+
params: { serviceName, json },
759+
});
760+
deps.log(`Done`);
761+
return;
762+
}
763+
try {
764+
const encryptedStorage = await createEncryptedStorageFromConfig(deps.config);
765+
const apiCredentialStore = new ApiCredentialStore(
766+
deps.config.credentialStorePath,
767+
encryptedStorage
768+
);
769+
prepareService(deps.registry, apiCredentialStore, serviceName, json);
770+
deps.log(`Done`);
771+
} catch (error) {
772+
if (
773+
error instanceof UnknownServiceError ||
774+
error instanceof PrepareNotSupportedError ||
775+
error instanceof PrepareInputInvalidError
776+
) {
777+
deps.errorLog(`Error: ${error.message}`);
778+
deps.exit(1);
779+
}
780+
throw error;
781+
}
782+
});
783+
737784
program
738785
.command('curl')
739786
.description('Run curl with API credential injection.')

src/gateway/latchkeyEndpoint.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
authList,
1818
authBrowser,
1919
authBrowserPrepare,
20+
prepareService,
2021
UnknownServiceError,
2122
BrowserNotConfiguredError,
2223
PreparationRequiredError,
@@ -26,7 +27,12 @@ import {
2627
BrowserFlowsNotSupportedError,
2728
GraphicalEnvironmentNotFoundError,
2829
} from '../playwrightUtils.js';
29-
import { LoginCancelledError, LoginFailedError } from '../services/index.js';
30+
import {
31+
LoginCancelledError,
32+
LoginFailedError,
33+
PrepareInputInvalidError,
34+
PrepareNotSupportedError,
35+
} from '../services/index.js';
3036

3137
const serviceNameParamsMessage = "missing required argument 'service_name'";
3238
const serviceNameParams = z.object(
@@ -68,12 +74,20 @@ const AuthBrowserPrepareRequestSchema = z.object({
6874
params: serviceNameParams,
6975
});
7076

77+
const PrepareRequestSchema = z.object({
78+
command: z.literal('auth prepare'),
79+
params: serviceNameParams.extend({
80+
json: z.string(),
81+
}),
82+
});
83+
7184
export const LatchkeyRequestSchema = z.discriminatedUnion('command', [
7285
ServicesListRequestSchema,
7386
ServicesInfoRequestSchema,
7487
AuthListRequestSchema,
7588
AuthBrowserRequestSchema,
7689
AuthBrowserPrepareRequestSchema,
90+
PrepareRequestSchema,
7791
]);
7892

7993
export type LatchkeyRequest = z.infer<typeof LatchkeyRequestSchema>;
@@ -87,6 +101,8 @@ const KNOWN_ERROR_CLASSES: readonly (abstract new (...args: never[]) => Error)[]
87101
PreparationRequiredError,
88102
LoginCancelledError,
89103
LoginFailedError,
104+
PrepareNotSupportedError,
105+
PrepareInputInvalidError,
90106
];
91107

92108
function isKnownError(error: unknown): error is Error {
@@ -158,6 +174,14 @@ async function dispatch(
158174
deps.config,
159175
parsed.params.serviceName
160176
);
177+
178+
case 'auth prepare':
179+
return prepareService(
180+
deps.registry,
181+
apiCredentialStore,
182+
parsed.params.serviceName,
183+
parsed.params.json
184+
);
161185
}
162186
}
163187

src/services/core/base.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
*/
44

55
import type { Browser, BrowserContext, Page, Response } from 'playwright';
6+
import type { z, ZodTypeAny } from 'zod';
67
import {
78
ApiCredentialStatus,
89
ApiCredentials,
@@ -38,6 +39,58 @@ export class LoginFailedError extends Error {
3839
}
3940
}
4041

42+
/**
43+
* Thrown when `latchkey auth prepare` is run for a service that does not declare a
44+
* prepare schema (the base default — services opt in by setting one).
45+
*/
46+
export class PrepareNotSupportedError extends Error {
47+
constructor(serviceName: string) {
48+
super(
49+
`Service '${serviceName}' does not support 'latchkey auth prepare'. ` +
50+
`Use 'latchkey services info ${serviceName}' to see how to authenticate.`
51+
);
52+
this.name = 'PrepareNotSupportedError';
53+
}
54+
}
55+
56+
/**
57+
* Thrown when the JSON passed to `latchkey auth prepare` is malformed or does not
58+
* match the service's prepare schema. The whole command is rejected and
59+
* nothing is stored.
60+
*/
61+
export class PrepareInputInvalidError extends Error {
62+
constructor(serviceName: string, detail: string) {
63+
super(`Invalid prepare input for '${serviceName}': ${detail}`);
64+
this.name = 'PrepareInputInvalidError';
65+
}
66+
}
67+
68+
/**
69+
* Validate a parsed JSON value against a service's prepare schema and build the
70+
* resulting credentials. Centralizes validation so each service's
71+
* `prepareFromJson` only expresses its schema and build step. Throws
72+
* `PrepareInputInvalidError` (with the failing fields) on any schema mismatch;
73+
* nothing is built unless the input fully validates.
74+
*/
75+
export function buildPreparedCredentials<Schema extends ZodTypeAny>(
76+
serviceName: string,
77+
schema: Schema,
78+
parsedJson: unknown,
79+
build: (validatedInput: z.infer<Schema>) => ApiCredentials
80+
): ApiCredentials {
81+
const result = schema.safeParse(parsedJson);
82+
if (!result.success) {
83+
const detail = result.error.issues
84+
.map((issue) => {
85+
const path = issue.path.join('.');
86+
return path ? `${path}: ${issue.message}` : issue.message;
87+
})
88+
.join('; ');
89+
throw new PrepareInputInvalidError(serviceName, detail);
90+
}
91+
return build(result.data as z.infer<Schema>);
92+
}
93+
4194
export function isBrowserClosedError(error: Error): boolean {
4295
const message = error.message.toLowerCase();
4396
return (
@@ -50,6 +103,22 @@ export function isBrowserClosedError(error: Error): boolean {
50103
);
51104
}
52105

106+
/**
107+
* Detects the Playwright/CDP error raised when a response body can no longer be
108+
* retrieved (`Network.getResponseBody` reports "No resource with given
109+
* identifier found"). This happens for responses that retain no readable body —
110+
* redirects, evicted or cached resources, or bodies fetched after the page has
111+
* navigated onward. Callers that read response bodies opportunistically should
112+
* treat this as inconclusive rather than fatal.
113+
*/
114+
export function isResponseBodyUnavailableError(error: Error): boolean {
115+
const message = error.message.toLowerCase();
116+
return (
117+
message.includes('no resource with given identifier') ||
118+
message.includes('network.getresponsebody')
119+
);
120+
}
121+
53122
export function isTimeoutError(error: Error): boolean {
54123
return error.name === 'TimeoutError';
55124
}
@@ -126,6 +195,18 @@ export abstract class Service {
126195
throw new NoCurlCredentialsNotSupportedError(this.name);
127196
}
128197

198+
/**
199+
* Build credentials from a parsed JSON payload for `latchkey auth prepare`.
200+
*
201+
* Optional, like `getSession`/`refreshCredentials`: services opt in by
202+
* implementing it (typically via `buildPreparedCredentials` with a Zod
203+
* schema). When a service does not implement it, prepare is "not supported"
204+
* — the default that lets every service stay closed until it declares a
205+
* schema. Implementations validate `parsedJson` and throw
206+
* `PrepareInputInvalidError` on mismatch.
207+
*/
208+
prepareFromJson?(parsedJson: unknown): ApiCredentials;
209+
129210
/**
130211
* Get a new session for the login flow.
131212
* Services that don't support browser login should not implement this method.

src/services/google/base.ts

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,15 +20,19 @@ import {
2020
} from '../../playwrightUtils.js';
2121
import {
2222
exchangeCodeForTokens,
23+
generateCodeChallenge,
24+
generateCodeVerifier,
2325
refreshAccessToken,
2426
startOAuthCallbackServer,
2527
} from '../../oauthUtils.js';
2628
import {
2729
Service,
2830
BrowserFollowupServiceSession,
31+
buildPreparedCredentials,
2932
LoginFailedError,
3033
LoginCancelledError,
3134
isBrowserClosedError,
35+
isResponseBodyUnavailableError,
3236
isTimeoutError,
3337
} from '../core/base.js';
3438
import type { EncryptedStorage } from '../../encryptedStorage.js';
@@ -573,9 +577,14 @@ function checkGoogleLoginResponse(
573577
.catch((error: unknown) => {
574578
// The response body can become unreadable if the page/context
575579
// closes while it's still being read (e.g. the automation
576-
// navigates onward, or the user closes the browser). Treat that
577-
// specific race as inconclusive; let any other error propagate.
578-
if (error instanceof Error && isBrowserClosedError(error)) {
580+
// navigates onward, or the user closes the browser), or if the
581+
// response simply retains no readable body (redirects, cached or
582+
// evicted resources). Login detection here is best-effort, so treat
583+
// those cases as inconclusive; let any other error propagate.
584+
if (
585+
error instanceof Error &&
586+
(isBrowserClosedError(error) || isResponseBodyUnavailableError(error))
587+
) {
579588
return;
580589
}
581590
throw error;
@@ -829,13 +838,21 @@ class GoogleServiceSession extends BrowserFollowupServiceSession {
829838
);
830839
const redirectUri = `http://localhost:${port.toString()}/oauth2callback`;
831840

841+
// PKCE (RFC 7636): bind the authorization code to a one-time verifier so a
842+
// stolen code cannot be redeemed without it. We keep sending the client
843+
// secret too so this is confidential-client + PKCE, defense-in-depth.
844+
const codeVerifier = generateCodeVerifier();
845+
const codeChallenge = generateCodeChallenge(codeVerifier);
846+
832847
const authUrl = new URL('https://accounts.google.com/o/oauth2/v2/auth');
833848
authUrl.searchParams.set('client_id', clientId);
834849
authUrl.searchParams.set('redirect_uri', redirectUri);
835850
authUrl.searchParams.set('response_type', 'code');
836851
authUrl.searchParams.set('scope', allScopes.join(' '));
837852
authUrl.searchParams.set('access_type', 'offline');
838853
authUrl.searchParams.set('prompt', 'consent');
854+
authUrl.searchParams.set('code_challenge', codeChallenge);
855+
authUrl.searchParams.set('code_challenge_method', 'S256');
839856

840857
await page.goto(authUrl.toString());
841858

@@ -845,7 +862,8 @@ class GoogleServiceSession extends BrowserFollowupServiceSession {
845862
code,
846863
clientId,
847864
clientSecret,
848-
redirectUri
865+
redirectUri,
866+
codeVerifier
849867
);
850868
const accessTokenExpiresAt = new Date(Date.now() + tokens.expires_in * 1000).toISOString();
851869

@@ -866,6 +884,20 @@ class GoogleServiceSession extends BrowserFollowupServiceSession {
866884
}
867885
}
868886

887+
/**
888+
* JSON accepted by `latchkey auth prepare <google-service>`: the OAuth client
889+
* credentials to use for that service. `.strict()` rejects unknown keys so
890+
* typos are reported instead of silently ignored.
891+
*/
892+
export const GooglePrepareInputSchema = z
893+
.object({
894+
clientId: z.string().min(1),
895+
clientSecret: z.string().min(1),
896+
})
897+
.strict();
898+
899+
export type GooglePrepareInput = z.infer<typeof GooglePrepareInputSchema>;
900+
869901
/**
870902
* Abstract base class for individual Google API services.
871903
*
@@ -878,6 +910,19 @@ export abstract class GoogleService extends Service {
878910

879911
protected abstract readonly config: GoogleServiceConfig;
880912

913+
/**
914+
* Google services accept an OAuth client's id/secret prepared
915+
* in advance via `latchkey auth prepare`, stored as token-less OAuth credentials until login.
916+
*/
917+
override prepareFromJson(parsedJson: unknown): ApiCredentials {
918+
return buildPreparedCredentials(
919+
this.name,
920+
GooglePrepareInputSchema,
921+
parsedJson,
922+
({ clientId, clientSecret }) => new OAuthCredentials(clientId, clientSecret)
923+
);
924+
}
925+
881926
setCredentialsExample(serviceName: string): string {
882927
return `latchkey auth set ${serviceName} -H "Authorization: Bearer <token>"`;
883928
}

src/services/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ export {
1212
LoginCancelledError,
1313
LoginFailedError,
1414
NoCurlCredentialsNotSupportedError,
15+
PrepareNotSupportedError,
16+
PrepareInputInvalidError,
1517
} from './core/base.js';
1618
export { RegisteredService } from './core/registered.js';
1719

0 commit comments

Comments
 (0)