From 4c80ee7589da6a843678b9fa4a1571c7e0861b49 Mon Sep 17 00:00:00 2001 From: Jeremy lewi Date: Sat, 7 Mar 2026 08:44:01 -0800 Subject: [PATCH 1/4] Remove client_secret from Google Drive auth flow Signed-off-by: Jeremy lewi --- app/README.md | 6 ++--- app/assets/configs/app-configs.yaml | 1 - app/src/auth/oidcConfig.ts | 4 ++-- app/src/contexts/GoogleAuthContext.tsx | 10 ++------- app/src/lib/appConfig.ts | 5 ----- app/src/lib/googleClientManager.ts | 31 -------------------------- app/src/lib/runtime/appJsGlobals.ts | 3 --- 7 files changed, 6 insertions(+), 54 deletions(-) diff --git a/app/README.md b/app/README.md index cad566c3..f3e81b0d 100644 --- a/app/README.md +++ b/app/README.md @@ -28,7 +28,7 @@ an OAuth credential with the following settings If you plan on hosting the app at your own domain you'll need to add the redirect URI at which your app gets served -Save the clientID and client secret you'll need them in the next step. +Save the clientID; you'll need it in the next step. ## Configure the web app @@ -37,7 +37,6 @@ Open the web app at [http://localhost:5173](http://localhost:5173). Expand the App Console at the bottom of the screen and run the following commands to configure your oauth client. ```ini -credentials.google.setClientSecret("") credentials.google.setClientId("") ``` @@ -76,7 +75,7 @@ In the app console run the following to set the discovery URL and scopes oidc.setGoogleDefaults() ``` -Then run the following to set your clientID and clientSecret to the same ones you set above for Google Drive. +Then run the following to set your clientID to the same one you set above for Google Drive. ```sh oidc.setClientToDrive() @@ -136,7 +135,6 @@ go run ./ agent --config=${HOME}/.runme-agent/config.dev.yaml serve googleDrive: clientID: "44661292282-bqhl39ugf2kn7r8vv4f6766jt0a7tom9.apps.googleusercontent.com" - clientSecret: "" chatkit: domainKey: "" diff --git a/app/assets/configs/app-configs.yaml b/app/assets/configs/app-configs.yaml index b105f5ac..dbcdfe94 100644 --- a/app/assets/configs/app-configs.yaml +++ b/app/assets/configs/app-configs.yaml @@ -12,7 +12,6 @@ oidc: googleDrive: clientID: "554943104515-bdt3on71kvc489nvi3l37gialolcnk0a.apps.googleusercontent.com" - clientSecret: "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" # Supported values: implicit (popup token flow), pkce (redirect code flow). authFlow: "pkce" authUxMode: "redirect" diff --git a/app/src/auth/oidcConfig.ts b/app/src/auth/oidcConfig.ts index 24331475..dee40c9e 100644 --- a/app/src/auth/oidcConfig.ts +++ b/app/src/auth/oidcConfig.ts @@ -89,8 +89,8 @@ export class OidcConfigManager { } setClientToDrive(): OidcConfig { - const { clientId, clientSecret } = googleClientManager.getOAuthClient(); - return this.setConfig({ clientId, clientSecret }); + const { clientId } = googleClientManager.getOAuthClient(); + return this.setConfig({ clientId }); } setScope(scope: string): OidcConfig { diff --git a/app/src/contexts/GoogleAuthContext.tsx b/app/src/contexts/GoogleAuthContext.tsx index 35113890..82ccf923 100644 --- a/app/src/contexts/GoogleAuthContext.tsx +++ b/app/src/contexts/GoogleAuthContext.tsx @@ -226,7 +226,7 @@ export function GoogleAuthProvider({ children }: { children: ReactNode }) { const exchangeAuthorizationCode = useCallback( async (code: string, codeVerifier: string): Promise => { - const { clientId, clientSecret } = googleClientManager.getOAuthClient(); + const { clientId } = googleClientManager.getOAuthClient(); if (!clientId?.trim()) { throw new Error("Google OAuth client is not configured."); } @@ -238,9 +238,6 @@ export function GoogleAuthProvider({ children }: { children: ReactNode }) { redirect_uri: getGoogleDriveOAuthCallbackUrl(), grant_type: "authorization_code", }); - if (clientSecret?.trim()) { - body.set("client_secret", clientSecret.trim()); - } const response = await fetch("https://oauth2.googleapis.com/token", { method: "POST", @@ -365,7 +362,7 @@ export function GoogleAuthProvider({ children }: { children: ReactNode }) { return null; } - const { clientId, clientSecret } = googleClientManager.getOAuthClient(); + const { clientId } = googleClientManager.getOAuthClient(); if (!clientId?.trim()) { throw new Error("Google OAuth client is not configured."); } @@ -375,9 +372,6 @@ export function GoogleAuthProvider({ children }: { children: ReactNode }) { grant_type: "refresh_token", refresh_token: refreshToken, }); - if (clientSecret?.trim()) { - body.set("client_secret", clientSecret.trim()); - } const response = await fetch("https://oauth2.googleapis.com/token", { method: "POST", diff --git a/app/src/lib/appConfig.ts b/app/src/lib/appConfig.ts index 41df96c6..5defee06 100644 --- a/app/src/lib/appConfig.ts +++ b/app/src/lib/appConfig.ts @@ -57,7 +57,6 @@ export interface OidcRuntimeConfig { export interface GoogleDriveRuntimeConfig { clientId: string; - clientSecret: string; baseUrl: string; authFlow: GoogleDriveAuthFlow; authUxMode: GoogleDriveAuthUxMode; @@ -370,7 +369,6 @@ function createDefaultRuntimeAppConfig(): RuntimeAppConfig { }, googleDrive: { clientId: "", - clientSecret: "", baseUrl: "", authFlow: "implicit", authUxMode: "popup", @@ -446,7 +444,6 @@ export class RuntimeAppConfigSchema { ) ?? (authFlow === "pkce" ? "redirect" : "popup"); parsed.googleDrive = { clientId: pickString(drive, ["clientId", "clientID"]), - clientSecret: pickString(drive, ["clientSecret", "client_secret"]), baseUrl: asNonEmptyString(drive.baseUrl), authFlow, authUxMode, @@ -566,7 +563,6 @@ export function applyAppConfig( } const googleClientId = normalizeString(parsed.googleDrive.clientId); - const googleClientSecret = normalizeString(parsed.googleDrive.clientSecret); const googleAuthFlow = parsed.googleDrive.authFlow; const googleAuthUxMode = parsed.googleDrive.authUxMode; const skipGoogleDriveFromConfig = @@ -578,7 +574,6 @@ export function applyAppConfig( try { googleOAuth = googleClientManager.setOAuthClient({ clientId: googleClientId, - clientSecret: googleClientSecret, authFlow: googleAuthFlow, authUxMode: googleAuthUxMode, }); diff --git a/app/src/lib/googleClientManager.ts b/app/src/lib/googleClientManager.ts index de36bf90..2c1aa318 100644 --- a/app/src/lib/googleClientManager.ts +++ b/app/src/lib/googleClientManager.ts @@ -1,6 +1,5 @@ export type GoogleOAuthClientConfig = { clientId: string; - clientSecret?: string; authFlow: GoogleDriveAuthFlow; authUxMode: GoogleDriveAuthUxMode; }; @@ -54,18 +53,15 @@ export class GoogleClientManager { private constructor() { const defaultClientId = ""; const storedClientId = this.readOAuthClientIdFromStorage(); - const storedClientSecret = this.readOAuthClientSecretFromStorage(); const storedAuthFlow = this.readOAuthAuthFlowFromStorage(); const storedAuthUxMode = this.readOAuthAuthUxModeFromStorage(); const resolvedClientId = storedClientId ?? defaultClientId; - const resolvedClientSecret = storedClientSecret ?? undefined; const resolvedAuthFlow = storedAuthFlow ?? DEFAULT_AUTH_FLOW; const resolvedAuthUxMode = storedAuthUxMode ?? resolveDefaultUxModeForFlow(resolvedAuthFlow); this.config = { oauth: { clientId: resolvedClientId, - clientSecret: resolvedClientSecret, authFlow: resolvedAuthFlow, authUxMode: resolvedAuthUxMode, }, @@ -129,10 +125,6 @@ export class GoogleClientManager { return this.config.oauth; } - setClientSecret(clientSecret: string): GoogleOAuthClientConfig { - return this.setOAuthClient({ clientSecret }); - } - setAuthFlow(authFlow: GoogleDriveAuthFlow): GoogleOAuthClientConfig { return this.setOAuthClient({ authFlow }); } @@ -146,7 +138,6 @@ export class GoogleClientManager { | { client_id?: string; clientId?: string; - client_secret?: string; auth_flow?: string; authFlow?: string; oauthFlow?: string; @@ -161,7 +152,6 @@ export class GoogleClientManager { parsed = JSON.parse(raw) as { client_id?: string; clientId?: string; - client_secret?: string; auth_flow?: string; authFlow?: string; oauthFlow?: string; @@ -179,7 +169,6 @@ export class GoogleClientManager { if (!clientId) { throw new Error("OAuth client config is missing client_id"); } - const clientSecret = parsed?.client_secret?.trim() ?? ""; const rawAuthFlow = parsed?.auth_flow ?? parsed?.authFlow ?? parsed?.oauthFlow ?? parsed?.flow; const rawAuthUxMode = @@ -202,7 +191,6 @@ export class GoogleClientManager { return this.setOAuthClient({ clientId, - clientSecret: clientSecret.length > 0 ? clientSecret : undefined, authFlow: rawAuthFlow?.trim() as GoogleDriveAuthFlow | undefined, authUxMode: rawAuthUxMode?.trim() as GoogleDriveAuthUxMode | undefined, }); @@ -240,24 +228,6 @@ export class GoogleClientManager { } } - private readOAuthClientSecretFromStorage(): string | null { - if (typeof window === "undefined" || !window.localStorage) { - return null; - } - try { - const raw = window.localStorage.getItem(STORAGE_KEY); - if (!raw) { - return null; - } - const parsed = JSON.parse(raw) as { oauthClientSecret?: string } | null; - const clientSecret = parsed?.oauthClientSecret?.trim(); - return clientSecret && clientSecret.length > 0 ? clientSecret : null; - } catch (error) { - console.warn("Failed to read Google OAuth client config", error); - return null; - } - } - private readOAuthAuthFlowFromStorage(): GoogleDriveAuthFlow | null { if (typeof window === "undefined" || !window.localStorage) { return null; @@ -305,7 +275,6 @@ export class GoogleClientManager { STORAGE_KEY, JSON.stringify({ oauthClientId: config.clientId, - oauthClientSecret: config.clientSecret, oauthAuthFlow: config.authFlow, oauthAuthUxMode: config.authUxMode, }), diff --git a/app/src/lib/runtime/appJsGlobals.ts b/app/src/lib/runtime/appJsGlobals.ts index 793857ef..7406212b 100644 --- a/app/src/lib/runtime/appJsGlobals.ts +++ b/app/src/lib/runtime/appJsGlobals.ts @@ -338,14 +338,11 @@ export function createAppJsGlobals({ get: () => googleClientManager.getOAuthClient(), setOAuthClient: (config: { clientId?: string; - clientSecret?: string; authFlow?: "implicit" | "pkce"; authUxMode?: "popup" | "redirect"; }) => googleClientManager.setOAuthClient(config), setClientId: (clientId: string) => googleClientManager.setOAuthClient({ clientId }), - setClientSecret: (clientSecret: string) => - googleClientManager.setClientSecret(clientSecret), setAuthFlow: (authFlow: "implicit" | "pkce") => googleClientManager.setAuthFlow(authFlow), setAuthUxMode: (authUxMode: "popup" | "redirect") => From fe6767ab99f501658be3e98339897969918325d1 Mon Sep 17 00:00:00 2001 From: Jeremy lewi Date: Sat, 7 Mar 2026 08:44:10 -0800 Subject: [PATCH 2/4] Revert "Remove client_secret from Google Drive auth flow" This reverts commit 4c80ee7589da6a843678b9fa4a1571c7e0861b49. --- app/README.md | 6 +++-- app/assets/configs/app-configs.yaml | 1 + app/src/auth/oidcConfig.ts | 4 ++-- app/src/contexts/GoogleAuthContext.tsx | 10 +++++++-- app/src/lib/appConfig.ts | 5 +++++ app/src/lib/googleClientManager.ts | 31 ++++++++++++++++++++++++++ app/src/lib/runtime/appJsGlobals.ts | 3 +++ 7 files changed, 54 insertions(+), 6 deletions(-) diff --git a/app/README.md b/app/README.md index f3e81b0d..cad566c3 100644 --- a/app/README.md +++ b/app/README.md @@ -28,7 +28,7 @@ an OAuth credential with the following settings If you plan on hosting the app at your own domain you'll need to add the redirect URI at which your app gets served -Save the clientID; you'll need it in the next step. +Save the clientID and client secret you'll need them in the next step. ## Configure the web app @@ -37,6 +37,7 @@ Open the web app at [http://localhost:5173](http://localhost:5173). Expand the App Console at the bottom of the screen and run the following commands to configure your oauth client. ```ini +credentials.google.setClientSecret("") credentials.google.setClientId("") ``` @@ -75,7 +76,7 @@ In the app console run the following to set the discovery URL and scopes oidc.setGoogleDefaults() ``` -Then run the following to set your clientID to the same one you set above for Google Drive. +Then run the following to set your clientID and clientSecret to the same ones you set above for Google Drive. ```sh oidc.setClientToDrive() @@ -135,6 +136,7 @@ go run ./ agent --config=${HOME}/.runme-agent/config.dev.yaml serve googleDrive: clientID: "44661292282-bqhl39ugf2kn7r8vv4f6766jt0a7tom9.apps.googleusercontent.com" + clientSecret: "" chatkit: domainKey: "" diff --git a/app/assets/configs/app-configs.yaml b/app/assets/configs/app-configs.yaml index dbcdfe94..b105f5ac 100644 --- a/app/assets/configs/app-configs.yaml +++ b/app/assets/configs/app-configs.yaml @@ -12,6 +12,7 @@ oidc: googleDrive: clientID: "554943104515-bdt3on71kvc489nvi3l37gialolcnk0a.apps.googleusercontent.com" + clientSecret: "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" # Supported values: implicit (popup token flow), pkce (redirect code flow). authFlow: "pkce" authUxMode: "redirect" diff --git a/app/src/auth/oidcConfig.ts b/app/src/auth/oidcConfig.ts index dee40c9e..24331475 100644 --- a/app/src/auth/oidcConfig.ts +++ b/app/src/auth/oidcConfig.ts @@ -89,8 +89,8 @@ export class OidcConfigManager { } setClientToDrive(): OidcConfig { - const { clientId } = googleClientManager.getOAuthClient(); - return this.setConfig({ clientId }); + const { clientId, clientSecret } = googleClientManager.getOAuthClient(); + return this.setConfig({ clientId, clientSecret }); } setScope(scope: string): OidcConfig { diff --git a/app/src/contexts/GoogleAuthContext.tsx b/app/src/contexts/GoogleAuthContext.tsx index 82ccf923..35113890 100644 --- a/app/src/contexts/GoogleAuthContext.tsx +++ b/app/src/contexts/GoogleAuthContext.tsx @@ -226,7 +226,7 @@ export function GoogleAuthProvider({ children }: { children: ReactNode }) { const exchangeAuthorizationCode = useCallback( async (code: string, codeVerifier: string): Promise => { - const { clientId } = googleClientManager.getOAuthClient(); + const { clientId, clientSecret } = googleClientManager.getOAuthClient(); if (!clientId?.trim()) { throw new Error("Google OAuth client is not configured."); } @@ -238,6 +238,9 @@ export function GoogleAuthProvider({ children }: { children: ReactNode }) { redirect_uri: getGoogleDriveOAuthCallbackUrl(), grant_type: "authorization_code", }); + if (clientSecret?.trim()) { + body.set("client_secret", clientSecret.trim()); + } const response = await fetch("https://oauth2.googleapis.com/token", { method: "POST", @@ -362,7 +365,7 @@ export function GoogleAuthProvider({ children }: { children: ReactNode }) { return null; } - const { clientId } = googleClientManager.getOAuthClient(); + const { clientId, clientSecret } = googleClientManager.getOAuthClient(); if (!clientId?.trim()) { throw new Error("Google OAuth client is not configured."); } @@ -372,6 +375,9 @@ export function GoogleAuthProvider({ children }: { children: ReactNode }) { grant_type: "refresh_token", refresh_token: refreshToken, }); + if (clientSecret?.trim()) { + body.set("client_secret", clientSecret.trim()); + } const response = await fetch("https://oauth2.googleapis.com/token", { method: "POST", diff --git a/app/src/lib/appConfig.ts b/app/src/lib/appConfig.ts index 5defee06..41df96c6 100644 --- a/app/src/lib/appConfig.ts +++ b/app/src/lib/appConfig.ts @@ -57,6 +57,7 @@ export interface OidcRuntimeConfig { export interface GoogleDriveRuntimeConfig { clientId: string; + clientSecret: string; baseUrl: string; authFlow: GoogleDriveAuthFlow; authUxMode: GoogleDriveAuthUxMode; @@ -369,6 +370,7 @@ function createDefaultRuntimeAppConfig(): RuntimeAppConfig { }, googleDrive: { clientId: "", + clientSecret: "", baseUrl: "", authFlow: "implicit", authUxMode: "popup", @@ -444,6 +446,7 @@ export class RuntimeAppConfigSchema { ) ?? (authFlow === "pkce" ? "redirect" : "popup"); parsed.googleDrive = { clientId: pickString(drive, ["clientId", "clientID"]), + clientSecret: pickString(drive, ["clientSecret", "client_secret"]), baseUrl: asNonEmptyString(drive.baseUrl), authFlow, authUxMode, @@ -563,6 +566,7 @@ export function applyAppConfig( } const googleClientId = normalizeString(parsed.googleDrive.clientId); + const googleClientSecret = normalizeString(parsed.googleDrive.clientSecret); const googleAuthFlow = parsed.googleDrive.authFlow; const googleAuthUxMode = parsed.googleDrive.authUxMode; const skipGoogleDriveFromConfig = @@ -574,6 +578,7 @@ export function applyAppConfig( try { googleOAuth = googleClientManager.setOAuthClient({ clientId: googleClientId, + clientSecret: googleClientSecret, authFlow: googleAuthFlow, authUxMode: googleAuthUxMode, }); diff --git a/app/src/lib/googleClientManager.ts b/app/src/lib/googleClientManager.ts index 2c1aa318..de36bf90 100644 --- a/app/src/lib/googleClientManager.ts +++ b/app/src/lib/googleClientManager.ts @@ -1,5 +1,6 @@ export type GoogleOAuthClientConfig = { clientId: string; + clientSecret?: string; authFlow: GoogleDriveAuthFlow; authUxMode: GoogleDriveAuthUxMode; }; @@ -53,15 +54,18 @@ export class GoogleClientManager { private constructor() { const defaultClientId = ""; const storedClientId = this.readOAuthClientIdFromStorage(); + const storedClientSecret = this.readOAuthClientSecretFromStorage(); const storedAuthFlow = this.readOAuthAuthFlowFromStorage(); const storedAuthUxMode = this.readOAuthAuthUxModeFromStorage(); const resolvedClientId = storedClientId ?? defaultClientId; + const resolvedClientSecret = storedClientSecret ?? undefined; const resolvedAuthFlow = storedAuthFlow ?? DEFAULT_AUTH_FLOW; const resolvedAuthUxMode = storedAuthUxMode ?? resolveDefaultUxModeForFlow(resolvedAuthFlow); this.config = { oauth: { clientId: resolvedClientId, + clientSecret: resolvedClientSecret, authFlow: resolvedAuthFlow, authUxMode: resolvedAuthUxMode, }, @@ -125,6 +129,10 @@ export class GoogleClientManager { return this.config.oauth; } + setClientSecret(clientSecret: string): GoogleOAuthClientConfig { + return this.setOAuthClient({ clientSecret }); + } + setAuthFlow(authFlow: GoogleDriveAuthFlow): GoogleOAuthClientConfig { return this.setOAuthClient({ authFlow }); } @@ -138,6 +146,7 @@ export class GoogleClientManager { | { client_id?: string; clientId?: string; + client_secret?: string; auth_flow?: string; authFlow?: string; oauthFlow?: string; @@ -152,6 +161,7 @@ export class GoogleClientManager { parsed = JSON.parse(raw) as { client_id?: string; clientId?: string; + client_secret?: string; auth_flow?: string; authFlow?: string; oauthFlow?: string; @@ -169,6 +179,7 @@ export class GoogleClientManager { if (!clientId) { throw new Error("OAuth client config is missing client_id"); } + const clientSecret = parsed?.client_secret?.trim() ?? ""; const rawAuthFlow = parsed?.auth_flow ?? parsed?.authFlow ?? parsed?.oauthFlow ?? parsed?.flow; const rawAuthUxMode = @@ -191,6 +202,7 @@ export class GoogleClientManager { return this.setOAuthClient({ clientId, + clientSecret: clientSecret.length > 0 ? clientSecret : undefined, authFlow: rawAuthFlow?.trim() as GoogleDriveAuthFlow | undefined, authUxMode: rawAuthUxMode?.trim() as GoogleDriveAuthUxMode | undefined, }); @@ -228,6 +240,24 @@ export class GoogleClientManager { } } + private readOAuthClientSecretFromStorage(): string | null { + if (typeof window === "undefined" || !window.localStorage) { + return null; + } + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) { + return null; + } + const parsed = JSON.parse(raw) as { oauthClientSecret?: string } | null; + const clientSecret = parsed?.oauthClientSecret?.trim(); + return clientSecret && clientSecret.length > 0 ? clientSecret : null; + } catch (error) { + console.warn("Failed to read Google OAuth client config", error); + return null; + } + } + private readOAuthAuthFlowFromStorage(): GoogleDriveAuthFlow | null { if (typeof window === "undefined" || !window.localStorage) { return null; @@ -275,6 +305,7 @@ export class GoogleClientManager { STORAGE_KEY, JSON.stringify({ oauthClientId: config.clientId, + oauthClientSecret: config.clientSecret, oauthAuthFlow: config.authFlow, oauthAuthUxMode: config.authUxMode, }), diff --git a/app/src/lib/runtime/appJsGlobals.ts b/app/src/lib/runtime/appJsGlobals.ts index 7406212b..793857ef 100644 --- a/app/src/lib/runtime/appJsGlobals.ts +++ b/app/src/lib/runtime/appJsGlobals.ts @@ -338,11 +338,14 @@ export function createAppJsGlobals({ get: () => googleClientManager.getOAuthClient(), setOAuthClient: (config: { clientId?: string; + clientSecret?: string; authFlow?: "implicit" | "pkce"; authUxMode?: "popup" | "redirect"; }) => googleClientManager.setOAuthClient(config), setClientId: (clientId: string) => googleClientManager.setOAuthClient({ clientId }), + setClientSecret: (clientSecret: string) => + googleClientManager.setClientSecret(clientSecret), setAuthFlow: (authFlow: "implicit" | "pkce") => googleClientManager.setAuthFlow(authFlow), setAuthUxMode: (authUxMode: "popup" | "redirect") => From d4744c9891132bae8d950537a2057263fe1fb0c8 Mon Sep 17 00:00:00 2001 From: Jeremy lewi Date: Sat, 7 Mar 2026 10:49:36 -0800 Subject: [PATCH 3/4] Reproduce PKCE OAuth Flow. --- pkcetest/go.mod | 3 + pkcetest/google_drive_pkce_debug.go | 263 ++++++++++++++++++++++++++++ 2 files changed, 266 insertions(+) create mode 100644 pkcetest/go.mod create mode 100644 pkcetest/google_drive_pkce_debug.go diff --git a/pkcetest/go.mod b/pkcetest/go.mod new file mode 100644 index 00000000..18ba7454 --- /dev/null +++ b/pkcetest/go.mod @@ -0,0 +1,3 @@ +module github.com/runmedev/web/pkcetest + +go 1.24.0 diff --git a/pkcetest/google_drive_pkce_debug.go b/pkcetest/google_drive_pkce_debug.go new file mode 100644 index 00000000..e344aa1d --- /dev/null +++ b/pkcetest/google_drive_pkce_debug.go @@ -0,0 +1,263 @@ +package main + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "net/url" + "os" + "os/signal" + "strings" + "syscall" + "time" +) + +const ( + clientID = "554943104515-bdt3on71kvc489nvi3l37gialolcnk0a.apps.googleusercontent.com" + redirectHost = "localhost" + redirectPort = 5173 + redirectPath = "/gdrive/callback" + authEndpoint = "https://accounts.google.com/o/oauth2/v2/auth" + tokenEndpoint = "https://oauth2.googleapis.com/token" +) + +var driveScopes = []string{ + "https://www.googleapis.com/auth/drive", + "https://www.googleapis.com/auth/drive.install", +} + +type callbackResult struct { + Code string + State string + OAuthError string + OAuthDescription string +} + +type tokenResponse struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + RefreshToken string `json:"refresh_token"` + Scope string `json:"scope"` + TokenType string `json:"token_type"` + Error string `json:"error"` + ErrorDescription string `json:"error_description"` +} + +func main() { + if strings.Contains(clientID, "REPLACE_WITH_YOUR_CLIENT_ID") || strings.TrimSpace(clientID) == "" { + log.Fatalf("set clientID in %s before running", os.Args[0]) + } + + redirectURI := fmt.Sprintf("http://%s:%d%s", redirectHost, redirectPort, redirectPath) + state := mustRandomBase64URL(24) + codeVerifier := mustRandomBase64URL(64) + codeChallenge := computeS256Challenge(codeVerifier) + + callbackCh := make(chan callbackResult, 1) + server := newCallbackServer(state, callbackCh) + + go func() { + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Printf("callback server failed: %v", err) + callbackCh <- callbackResult{ + OAuthError: "server_error", + OAuthDescription: err.Error(), + } + } + }() + + authURL := buildAuthURL(redirectURI, state, codeChallenge) + fmt.Printf("Open this URL in your browser:\n\n%s\n\n", authURL) + fmt.Printf("Waiting for callback on %s ...\n", redirectURI) + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) + + var cb callbackResult + select { + case cb = <-callbackCh: + case sig := <-sigCh: + log.Fatalf("interrupted: %v", sig) + case <-time.After(5 * time.Minute): + log.Fatal("timed out waiting for OAuth callback") + } + + shutdownCtx, cancelShutdown := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelShutdown() + _ = server.Shutdown(shutdownCtx) + + if cb.OAuthError != "" { + log.Fatalf("oauth callback error: %s (%s)", cb.OAuthError, cb.OAuthDescription) + } + if cb.State != state { + log.Fatalf("state mismatch: got=%q want=%q", cb.State, state) + } + if cb.Code == "" { + log.Fatal("callback did not include authorization code") + } + + token, statusCode, rawBody, err := exchangeAuthorizationCode( + context.Background(), + cb.Code, + codeVerifier, + redirectURI, + ) + if err != nil { + log.Fatalf("token exchange request failed: %v", err) + } + + if statusCode != http.StatusOK || token.Error != "" || token.AccessToken == "" { + fmt.Println("TOKEN EXCHANGE FAILED") + fmt.Printf("HTTP status: %d\n", statusCode) + prettyPrintJSON(rawBody) + os.Exit(1) + } + + fmt.Println("TOKEN EXCHANGE SUCCEEDED") + fmt.Printf("token_type: %s\n", token.TokenType) + fmt.Printf("scope: %s\n", token.Scope) + fmt.Printf("expires_in: %d\n", token.ExpiresIn) + fmt.Printf("has_refresh_token: %t\n", strings.TrimSpace(token.RefreshToken) != "") + fmt.Printf("access_token_prefix: %s\n", prefix(token.AccessToken, 24)) +} + +func newCallbackServer(expectedState string, callbackCh chan<- callbackResult) *http.Server { + mux := http.NewServeMux() + mux.HandleFunc(redirectPath, func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + cb := callbackResult{ + Code: q.Get("code"), + State: q.Get("state"), + OAuthError: q.Get("error"), + OAuthDescription: q.Get("error_description"), + } + + if cb.OAuthError != "" { + http.Error(w, "OAuth failed. Check terminal output.", http.StatusBadRequest) + } else if cb.State != expectedState { + http.Error(w, "State mismatch. Check terminal output.", http.StatusBadRequest) + cb.OAuthError = "state_mismatch" + cb.OAuthDescription = fmt.Sprintf("got=%q expected=%q", cb.State, expectedState) + } else if cb.Code == "" { + http.Error(w, "Authorization code missing. Check terminal output.", http.StatusBadRequest) + cb.OAuthError = "missing_code" + cb.OAuthDescription = "no code query parameter in callback" + } else { + _, _ = io.WriteString(w, "Authorization received. You can return to the terminal.") + } + + select { + case callbackCh <- cb: + default: + } + }) + + mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, "PKCE debug server is running.") + }) + + return &http.Server{ + Addr: fmt.Sprintf("%s:%d", redirectHost, redirectPort), + Handler: mux, + ReadHeaderTimeout: 5 * time.Second, + } +} + +func buildAuthURL(redirectURI, state, codeChallenge string) string { + u, err := url.Parse(authEndpoint) + if err != nil { + log.Fatalf("invalid auth endpoint: %v", err) + } + q := u.Query() + q.Set("client_id", clientID) + q.Set("redirect_uri", redirectURI) + q.Set("response_type", "code") + q.Set("scope", strings.Join(driveScopes, " ")) + q.Set("state", state) + q.Set("code_challenge", codeChallenge) + q.Set("code_challenge_method", "S256") + q.Set("include_granted_scopes", "true") + q.Set("access_type", "offline") + q.Set("prompt", "consent") + u.RawQuery = q.Encode() + return u.String() +} + +func exchangeAuthorizationCode( + ctx context.Context, + code, codeVerifier, redirectURI string, +) (tokenResponse, int, []byte, error) { + form := url.Values{} + form.Set("client_id", clientID) + form.Set("code", code) + form.Set("code_verifier", codeVerifier) + form.Set("redirect_uri", redirectURI) + form.Set("grant_type", "authorization_code") + // Intentionally no client_secret. This matches the Drive PKCE browser flow. + + req, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + tokenEndpoint, + strings.NewReader(form.Encode()), + ) + if err != nil { + return tokenResponse{}, 0, nil, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return tokenResponse{}, 0, nil, err + } + defer resp.Body.Close() + + rawBody, err := io.ReadAll(resp.Body) + if err != nil { + return tokenResponse{}, resp.StatusCode, nil, err + } + + var token tokenResponse + _ = json.Unmarshal(rawBody, &token) + return token, resp.StatusCode, rawBody, nil +} + +func mustRandomBase64URL(numBytes int) string { + buf := make([]byte, numBytes) + if _, err := rand.Read(buf); err != nil { + log.Fatalf("failed to read random bytes: %v", err) + } + return base64.RawURLEncoding.EncodeToString(buf) +} + +func computeS256Challenge(verifier string) string { + sum := sha256.Sum256([]byte(verifier)) + return base64.RawURLEncoding.EncodeToString(sum[:]) +} + +func prettyPrintJSON(raw []byte) { + var anyVal any + if err := json.Unmarshal(raw, &anyVal); err != nil { + fmt.Printf("raw_response: %s\n", string(raw)) + return + } + pretty, err := json.MarshalIndent(anyVal, "", " ") + if err != nil { + fmt.Printf("raw_response: %s\n", string(raw)) + return + } + fmt.Printf("response:\n%s\n", string(pretty)) +} + +func prefix(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "..." +} From 1a4da35c79d92b3ad6a21c36586addb4c7abb3ad Mon Sep 17 00:00:00 2001 From: Jeremy lewi Date: Sun, 8 Mar 2026 16:35:35 -0700 Subject: [PATCH 4/4] Align PKCE debug tool with gcloud auth login flow Signed-off-by: Jeremy lewi --- pkcetest/google_drive_pkce_debug.go | 92 +++++++++++++++++++++-------- 1 file changed, 69 insertions(+), 23 deletions(-) diff --git a/pkcetest/google_drive_pkce_debug.go b/pkcetest/google_drive_pkce_debug.go index e344aa1d..01991600 100644 --- a/pkcetest/google_drive_pkce_debug.go +++ b/pkcetest/google_drive_pkce_debug.go @@ -9,6 +9,7 @@ import ( "fmt" "io" "log" + "net" "net/http" "net/url" "os" @@ -19,17 +20,31 @@ import ( ) const ( - clientID = "554943104515-bdt3on71kvc489nvi3l37gialolcnk0a.apps.googleusercontent.com" - redirectHost = "localhost" - redirectPort = 5173 - redirectPath = "/gdrive/callback" - authEndpoint = "https://accounts.google.com/o/oauth2/v2/auth" + // Matches Cloud SDK defaults in googlecloudsdk/core/config.py. + clientID = "32555940559.apps.googleusercontent.com" + clientSecret = "ZmssLNjJy2998hD4CTg2ejr2" + + authEndpoint = "https://accounts.google.com/o/oauth2/auth" tokenEndpoint = "https://oauth2.googleapis.com/token" + + redirectHost = "localhost" + redirectPath = "/" + + // Matches Cloud SDK loopback port probing in core/credentials/flow.py. + portSearchStart = 8085 + portSearchEnd = portSearchStart + 100 ) -var driveScopes = []string{ - "https://www.googleapis.com/auth/drive", - "https://www.googleapis.com/auth/drive.install", +// Matches CLOUDSDK_SCOPES + REAUTH_SCOPE in googlecloudsdk/core/config.py and +// lib/surface/auth/login.py:GetScopes. +var gcloudScopes = []string{ + "openid", + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/cloud-platform", + "https://www.googleapis.com/auth/appengine.admin", + "https://www.googleapis.com/auth/sqlservice.login", + "https://www.googleapis.com/auth/compute", + "https://www.googleapis.com/auth/accounts.reauth", } type callbackResult struct { @@ -45,26 +60,27 @@ type tokenResponse struct { RefreshToken string `json:"refresh_token"` Scope string `json:"scope"` TokenType string `json:"token_type"` + IDToken string `json:"id_token"` Error string `json:"error"` ErrorDescription string `json:"error_description"` } func main() { - if strings.Contains(clientID, "REPLACE_WITH_YOUR_CLIENT_ID") || strings.TrimSpace(clientID) == "" { - log.Fatalf("set clientID in %s before running", os.Args[0]) + redirectPort, err := pickLoopbackPort(redirectHost, portSearchStart, portSearchEnd) + if err != nil { + log.Fatalf("failed to reserve local callback port: %v", err) } - redirectURI := fmt.Sprintf("http://%s:%d%s", redirectHost, redirectPort, redirectPath) + state := mustRandomBase64URL(24) - codeVerifier := mustRandomBase64URL(64) + codeVerifier := mustPKCECodeVerifier(128) codeChallenge := computeS256Challenge(codeVerifier) callbackCh := make(chan callbackResult, 1) - server := newCallbackServer(state, callbackCh) + server := newCallbackServer(state, redirectPort, callbackCh) go func() { if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { - log.Printf("callback server failed: %v", err) callbackCh <- callbackResult{ OAuthError: "server_error", OAuthDescription: err.Error(), @@ -124,10 +140,16 @@ func main() { fmt.Printf("scope: %s\n", token.Scope) fmt.Printf("expires_in: %d\n", token.ExpiresIn) fmt.Printf("has_refresh_token: %t\n", strings.TrimSpace(token.RefreshToken) != "") + fmt.Printf("has_id_token: %t\n", strings.TrimSpace(token.IDToken) != "") + fmt.Printf("access_token: %s\n", token.AccessToken) fmt.Printf("access_token_prefix: %s\n", prefix(token.AccessToken, 24)) } -func newCallbackServer(expectedState string, callbackCh chan<- callbackResult) *http.Server { +func newCallbackServer( + expectedState string, + redirectPort int, + callbackCh chan<- callbackResult, +) *http.Server { mux := http.NewServeMux() mux.HandleFunc(redirectPath, func(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() @@ -158,10 +180,6 @@ func newCallbackServer(expectedState string, callbackCh chan<- callbackResult) * } }) - mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) { - _, _ = io.WriteString(w, "PKCE debug server is running.") - }) - return &http.Server{ Addr: fmt.Sprintf("%s:%d", redirectHost, redirectPort), Handler: mux, @@ -178,13 +196,11 @@ func buildAuthURL(redirectURI, state, codeChallenge string) string { q.Set("client_id", clientID) q.Set("redirect_uri", redirectURI) q.Set("response_type", "code") - q.Set("scope", strings.Join(driveScopes, " ")) + q.Set("scope", strings.Join(gcloudScopes, " ")) q.Set("state", state) q.Set("code_challenge", codeChallenge) q.Set("code_challenge_method", "S256") - q.Set("include_granted_scopes", "true") q.Set("access_type", "offline") - q.Set("prompt", "consent") u.RawQuery = q.Encode() return u.String() } @@ -195,11 +211,11 @@ func exchangeAuthorizationCode( ) (tokenResponse, int, []byte, error) { form := url.Values{} form.Set("client_id", clientID) + form.Set("client_secret", clientSecret) form.Set("code", code) form.Set("code_verifier", codeVerifier) form.Set("redirect_uri", redirectURI) form.Set("grant_type", "authorization_code") - // Intentionally no client_secret. This matches the Drive PKCE browser flow. req, err := http.NewRequestWithContext( ctx, @@ -236,11 +252,41 @@ func mustRandomBase64URL(numBytes int) string { return base64.RawURLEncoding.EncodeToString(buf) } +func mustPKCECodeVerifier(length int) string { + const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~" + if length < 43 || length > 128 { + log.Fatalf("invalid PKCE verifier length %d", length) + } + out := make([]byte, length) + randBuf := make([]byte, length) + if _, err := rand.Read(randBuf); err != nil { + log.Fatalf("failed to read random bytes: %v", err) + } + for i := range out { + out[i] = chars[int(randBuf[i])%len(chars)] + } + return string(out) +} + func computeS256Challenge(verifier string) string { sum := sha256.Sum256([]byte(verifier)) return base64.RawURLEncoding.EncodeToString(sum[:]) } +func pickLoopbackPort(host string, startPort, endPort int) (int, error) { + for p := startPort; p <= endPort; p++ { + l, err := net.Listen("tcp", fmt.Sprintf("%s:%d", host, p)) + if err != nil { + continue + } + if err := l.Close(); err != nil { + return 0, err + } + return p, nil + } + return 0, fmt.Errorf("no open port found between %d and %d", startPort, endPort) +} + func prettyPrintJSON(raw []byte) { var anyVal any if err := json.Unmarshal(raw, &anyVal); err != nil {