Skip to content

fix(oauth): preserve full callback URL for token exchange + persist Google Client ID#431

Merged
ramnique merged 1 commit intorowboatlabs:mainfrom
aculich:pr/google-oauth-callback
Apr 9, 2026
Merged

fix(oauth): preserve full callback URL for token exchange + persist Google Client ID#431
ramnique merged 1 commit intorowboatlabs:mainfrom
aculich:pr/google-oauth-callback

Conversation

@aculich
Copy link
Copy Markdown
Contributor

@aculich aculich commented Mar 18, 2026

Summary

Fixes two issues in the Google OAuth flow:

  1. Post-callback token exchange failure: When the user completes authorization in the browser and is redirected to http://localhost:8080/oauth/callback with code, state, and iss (issuer), the app was only passing code and state into the token exchange. The openid-client library validates the authorization response and expects the iss parameter (OpenID Connect). Because we rebuilt a minimal callback URL without iss, the library threw OperationProcessingError: response parameter "iss" (issuer) missing, which was surfaced to the user as "invalid response encountered". This PR passes the full callback URL from the auth server to the handler and uses it in exchangeCodeForTokens, so iss (and other params) are preserved and validation succeeds.

  2. Client ID not persisted: The Google Client ID was only written to ~/.rowboat/config/oauth.json when token exchange succeeded. If exchange failed (e.g. due to the bug above) or the user had not yet completed a successful flow, the Client ID was never saved. The UI also relied on an in-memory store that was not restored from disk, so users had to re-enter the Client ID after every restart or app update. This PR persists the Client ID when starting the OAuth flow (before opening the browser), exposes it via oauth:getState, and hydrates the UI so the modal can be pre-filled or skipped when a Client ID is already stored.

  3. Review follow-up: After refreshTokens() succeeds, the code previously upserted refreshedTokens to disk but getAccessToken could still return the pre-refresh access token from the initial read(). The refresh path now assigns the refreshed credentials before returning so the access token matches what was persisted. If the authorization server omits state on the redirect, we throw a clear missing state error instead of mislabeling it as a CSRF mismatch. getOAuthErrorMessage unwraps a one-level cause when the top-level message is an opaque openid-client string (e.g. "invalid response encountered"), in addition to OAUTH_INVALID_RESPONSE. Full cause chains are still logged in the catch block for debugging.

Related issues

  • Fixes #432 — Google OAuth shows "invalid response encountered" after successful browser authorization.
  • Fixes #391 — Google connection loses Client ID after updates.

Changes

  • apps/x/apps/main/src/auth-server.ts

    • Callback signature changed from (code: string, state: string) to (callbackUrl: URL). The handler receives the full request URL so query params such as iss and scope are preserved for token exchange.
  • apps/x/apps/main/src/oauth-handler.ts

    • Callback uses the full callbackUrl for state validation and passes it to exchangeCodeForTokens instead of building a minimal REDIRECT_URI?code=...&state=....
    • Missing or empty state on the redirect throws a dedicated error before the CSRF check.
    • Persists Google clientId when starting the flow (after getProviderConfiguration), so it is stored even if token exchange fails later.
    • getOAuthErrorMessage() surfaces underlying causes for OAUTH_INVALID_RESPONSE and opaque top-level messages; the token-exchange catch block logs the full cause chain for debugging.
    • Token refresh: upserts refreshedTokens after refreshTokens(), and getAccessToken returns the refreshed access token after a successful refresh (not the pre-refresh token object).
  • apps/x/apps/main/src/composio-handler.ts

    • Callback updated to accept (callbackUrl: URL) to match the new auth-server signature; Composio does not use the URL.
  • apps/x/packages/core/src/auth/repo.ts

    • ClientFacingConfigSchema and getClientFacingConfig() now include optional clientId per provider so the renderer can pre-fill or skip the Google Client ID modal.
  • apps/x/packages/shared/src/ipc.ts

    • oauth:getState response schema extended with optional clientId per provider.
  • apps/x/apps/renderer (connectors-popover, onboarding-modal)

    • When loading OAuth state, if config.google?.clientId is present, hydrate the in-memory store so the user is not prompted to re-enter after restart or when reconnecting.
  • google-setup.md

    • Authorized redirect URI and troubleshooting for browser success vs in-app error.

Testing

  1. cd apps/x && pnpm run deps && pnpm run lint; cd apps/main && npm run build.
  2. Run the app (./dev.sh or cd apps/x && npm run dev).
  3. Connect Google (or Reconnect): enter a valid OAuth Client ID (per google-setup.md). Complete the browser flow; you should see "Authorization Successful" in the browser and success in the app (no "invalid response encountered").
  4. Restart the app: Google Client ID should still be present (modal pre-filled or Reconnect usable without re-entering).

@vercel
Copy link
Copy Markdown

vercel Bot commented Mar 18, 2026

@aculich is attempting to deploy a commit to the RowBoat Labs Team on Vercel.

A member of the Team first needs to authorize it.

Copy link
Copy Markdown

@JiwaniZakir JiwaniZakir left a comment

Choose a reason for hiding this comment

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

The token-refresh bug fix in oauth-handler.ts line 349 (tokensrefreshedTokens) is the most impactful change here — the old code was persisting the pre-refresh token object after a successful refresh, meaning every subsequent call would re-enter the refresh path and any new access token was silently discarded. Worth calling out explicitly in the PR description.

In the new createAuthServer callback inside connectProvider, callbackUrl.searchParams.get('state') can return null if the provider omits the parameter entirely, so receivedState !== state would evaluate null !== <expected state> and surface a misleading "possible CSRF attack" error rather than something like "missing state parameter." A null-check before the comparison would make the failure mode clearer.

The getOAuthErrorMessage helper only unwraps the cause chain for OAUTH_INVALID_RESPONSE, but the debug logging loop added just below it walks the full cause chain for any error. The two approaches are inconsistent — if other openid-client error codes also wrap a more informative cause, the user-facing message will still be the opaque top-level one while only the console gets the useful detail.

aculich added a commit to aculich/rowboat that referenced this pull request Apr 6, 2026
…h return, error unwrap

- Return access token from refreshed credentials after refreshTokens in getAccessToken

- Reject OAuth callback with missing state before CSRF comparison

- Unwrap one-level cause for opaque openid-client top messages + OAUTH_INVALID_RESPONSE

- Add .devdocs drafts for PR body and upstream comments (publish after review)

Made-with: Cursor
@aculich aculich force-pushed the pr/google-oauth-callback branch from 01c699e to 7eb8a3e Compare April 6, 2026 15:16
aculich added a commit to aculich/rowboat that referenced this pull request Apr 6, 2026
…h return, error unwrap

- Return access token from refreshed credentials after refreshTokens in getAccessToken

- Reject OAuth callback with missing state before CSRF comparison

- Unwrap one-level cause for opaque openid-client top messages + OAUTH_INVALID_RESPONSE

- Add .devdocs drafts for PR body and upstream comments (publish after review)

Made-with: Cursor
@aculich
Copy link
Copy Markdown
Contributor Author

aculich commented Apr 6, 2026

Thanks for the detailed review — all three points addressed in the latest push:

  1. Token refresh: I expanded the PR description to call out the refresh fix explicitly. On the code side, we already persisted refreshedTokens after refreshTokens(); I also fixed a related bug where getAccessToken still returned the pre-refresh access token after a successful refresh, so callers could briefly use a stale access token.

  2. Missing state: The callback now checks for a missing or empty state before the CSRF comparison and throws a dedicated error instead of reporting a CSRF mismatch.

  3. User-facing vs logs: getOAuthErrorMessage unwraps a one-level cause for OAUTH_INVALID_RESPONSE and when the top-level message is an opaque openid-client string (e.g. "invalid response encountered"). The catch block still logs the full cause chain for any error for debugging.

@aculich aculich force-pushed the pr/google-oauth-callback branch from 1a2099a to d78c84b Compare April 7, 2026 18:23
aculich added a commit to aculich/rowboat that referenced this pull request Apr 7, 2026
…h return, error unwrap

- Return access token from refreshed credentials after refreshTokens in getAccessToken

- Reject OAuth callback with missing state before CSRF comparison

- Unwrap one-level cause for opaque openid-client top messages + OAUTH_INVALID_RESPONSE

- Add .devdocs drafts for PR body and upstream comments (publish after review)

Made-with: Cursor
…ollow-ups

- Pass full OAuth callback URL through auth-server for openid-client validation
- Composio + Google flows: duplicate-callback guard; preserve timeout cleanup
- Persist and expose Google clientId via oauth:getState; hydrate UI from useConnectors
- getAccessToken returns refreshed credentials; clearer errors and missing-state handling
- IPC schema: per-provider userId + clientId
- Docs: google-setup redirect URI and troubleshooting

Made-with: Cursor
@aculich aculich force-pushed the pr/google-oauth-callback branch from d78c84b to e1c6758 Compare April 7, 2026 18:24
@ramnique ramnique merged commit d854b3f into rowboatlabs:main Apr 9, 2026
2 checks passed
@ramnique
Copy link
Copy Markdown
Contributor

ramnique commented Apr 9, 2026

@aculich Thanks for the PR. Merged and released in https://github.com/rowboatlabs/rowboat/releases/tag/v0.2.1

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Google OAuth shows 'invalid response encountered' after successful browser authorization Google connection loses Client ID after updates

3 participants