fix(oauth): preserve full callback URL for token exchange + persist Google Client ID#431
Conversation
|
@aculich is attempting to deploy a commit to the RowBoat Labs Team on Vercel. A member of the Team first needs to authorize it. |
JiwaniZakir
left a comment
There was a problem hiding this comment.
The token-refresh bug fix in oauth-handler.ts line 349 (tokens → refreshedTokens) 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.
…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
01c699e to
7eb8a3e
Compare
…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
|
Thanks for the detailed review — all three points addressed in the latest push:
|
1a2099a to
d78c84b
Compare
…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
d78c84b to
e1c6758
Compare
|
@aculich Thanks for the PR. Merged and released in https://github.com/rowboatlabs/rowboat/releases/tag/v0.2.1 |
Summary
Fixes two issues in the Google OAuth flow:
Post-callback token exchange failure: When the user completes authorization in the browser and is redirected to
http://localhost:8080/oauth/callbackwithcode,state, andiss(issuer), the app was only passingcodeandstateinto the token exchange. The openid-client library validates the authorization response and expects theissparameter (OpenID Connect). Because we rebuilt a minimal callback URL withoutiss, the library threwOperationProcessingError: 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 inexchangeCodeForTokens, soiss(and other params) are preserved and validation succeeds.Client ID not persisted: The Google Client ID was only written to
~/.rowboat/config/oauth.jsonwhen 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 viaoauth:getState, and hydrates the UI so the modal can be pre-filled or skipped when a Client ID is already stored.Review follow-up: After
refreshTokens()succeeds, the code previously upsertedrefreshedTokensto disk butgetAccessTokencould still return the pre-refresh access token from the initialread(). The refresh path now assigns the refreshed credentials before returning so the access token matches what was persisted. If the authorization server omitsstateon the redirect, we throw a clear missing state error instead of mislabeling it as a CSRF mismatch.getOAuthErrorMessageunwraps a one-levelcausewhen the top-level message is an opaque openid-client string (e.g. "invalid response encountered"), in addition toOAUTH_INVALID_RESPONSE. Full cause chains are still logged in the catch block for debugging.Related issues
Changes
apps/x/apps/main/src/auth-server.ts
(code: string, state: string)to(callbackUrl: URL). The handler receives the full request URL so query params such asissandscopeare preserved for token exchange.apps/x/apps/main/src/oauth-handler.ts
callbackUrlfor state validation and passes it toexchangeCodeForTokensinstead of building a minimalREDIRECT_URI?code=...&state=....stateon the redirect throws a dedicated error before the CSRF check.clientIdwhen starting the flow (aftergetProviderConfiguration), so it is stored even if token exchange fails later.getOAuthErrorMessage()surfaces underlying causes forOAUTH_INVALID_RESPONSEand opaque top-level messages; the token-exchange catch block logs the full cause chain for debugging.refreshedTokensafterrefreshTokens(), andgetAccessTokenreturns the refreshed access token after a successful refresh (not the pre-refresh token object).apps/x/apps/main/src/composio-handler.ts
(callbackUrl: URL)to match the new auth-server signature; Composio does not use the URL.apps/x/packages/core/src/auth/repo.ts
ClientFacingConfigSchemaandgetClientFacingConfig()now include optionalclientIdper provider so the renderer can pre-fill or skip the Google Client ID modal.apps/x/packages/shared/src/ipc.ts
oauth:getStateresponse schema extended with optionalclientIdper provider.apps/x/apps/renderer (connectors-popover, onboarding-modal)
config.google?.clientIdis present, hydrate the in-memory store so the user is not prompted to re-enter after restart or when reconnecting.google-setup.md
Testing
cd apps/x && pnpm run deps && pnpm run lint;cd apps/main && npm run build../dev.shorcd apps/x && npm run dev).