@@ -20,15 +20,19 @@ import {
2020} from '../../playwrightUtils.js' ;
2121import {
2222 exchangeCodeForTokens ,
23+ generateCodeChallenge ,
24+ generateCodeVerifier ,
2325 refreshAccessToken ,
2426 startOAuthCallbackServer ,
2527} from '../../oauthUtils.js' ;
2628import {
2729 Service ,
2830 BrowserFollowupServiceSession ,
31+ buildPreparedCredentials ,
2932 LoginFailedError ,
3033 LoginCancelledError ,
3134 isBrowserClosedError ,
35+ isResponseBodyUnavailableError ,
3236 isTimeoutError ,
3337} from '../core/base.js' ;
3438import 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 }
0 commit comments