Skip to content

feat(api): one OAuth redirect URI for all providers via a unified callback - #464

Open
biztex wants to merge 1 commit into
onecli:mainfrom
biztex:feat/unified-oauth-callback
Open

feat(api): one OAuth redirect URI for all providers via a unified callback#464
biztex wants to merge 1 commit into
onecli:mainfrom
biztex:feat/unified-oauth-callback

Conversation

@biztex

@biztex biztex commented Jul 30, 2026

Copy link
Copy Markdown

I have read the CONTRIBUTING.md file.

YES

What kind of change does this PR introduce?

Feature — a developer-experience improvement to the OAuth connection flow. Closes #301.

What is the current behavior?

Every provider has its own OAuth callback (/v1/apps/:provider/callback), so connecting Google services against one OAuth app means registering a separate redirect URI in the Google console for each of gmail, google-drive, google-calendar, … (a dozen-plus), each addition subject to Google's propagation lag. Reported in #301, where consolidating to a single callback path via the state parameter was named as the direction to go — with rollout safety for existing users as the open question.

What is the new behavior?

One shared callback — GET /v1/apps/callback — usable by every OAuth provider. The provider is recovered from the HMAC-signed state minted at the authenticated /authorize (the state already carried provider; the new route verifies the signature before trusting it). Both callback routes share one handler, so the token exchange always repeats the exact redirect URI its authorization request used.

Rollout is opt-in per config row, so existing users keep working. The invariant: a config uses the unified URI if and only if it was saved through a surface that displayed the unified URI.

  • Saving a project app config through the dashboard stamps redirectStyle: "unified" into the row's settings. The config form now displays the unified URI — and saving a config already disconnects the provider's connections (refresh tokens are bound to the OAuth client), so no live flow straddles the switch.
  • Unstamped rows (saved before this change), env-default credentials, and org-scoped configs keep sending the per-provider URI their OAuth app already has registered. The per-provider callback route is untouched.
  • parseConfigBody strips unknown keys, so a client cannot inject or downgrade the stamp; the server owns it.

Fail-flat on bad state: the unified route returns a plain 400 when the state is missing, unverifiable, or names an unknown provider — without a verified provider there is no per-provider error page to redirect to, and nothing an attacker-forged state can steer (it already failed the HMAC).

Fragment-callback providers (Trello-shaped) work through the unified path too: their redirects carry no query params, and the oauth_state cookie — scoped at /authorize to the exact callback path in use — supplies the state, same as on the per-provider route.

Changes

  • packages/api/src/routes/apps.ts — unified GET /apps/callback route (registered before GET /:provider); /authorize picks the redirect URI and state-cookie path from the resolved config's redirect style; callback handler extracted and shared by both routes.
  • packages/api/src/services/app-config-service.tsupsertAppConfig stamps redirectStyle: "unified" on project-scope saves (org scope deliberately unstamped until the org surfaces display the unified URI).
  • packages/api/src/apps/resolve-credentials.ts — surfaces the stamp as ResolvedAppCredentials.redirectStyle.
  • apps/web/.../connections/_components/redirect-uri.tsx (+ two call sites) — project config forms display the unified URI; org-scoped forms keep the per-provider URI.

Tests

  • apps-unified-callback.test.ts (8 tests): /authorize sends the unified URI + cookie path only for stamped configs and keeps the per-provider URI otherwise; the unified callback completes the flow and hands the exchange the unified redirect URI; the fragment bridge resolves from the state cookie alone; 400 on missing/forged/unknown-provider state; the legacy per-provider callback is pinned unchanged.
  • app-config-service.test.ts (+4 tests): stamp applied on create and update, disconnect-before-upsert ordering, client-supplied redirectStyle ignored, org rows unstamped.
  • Full packages/api suite: 786 passed, 0 failed. turbo run lint check-types green for api/web/db/ui; prettier clean.

Additional context

  • Env-default (platform) credentials intentionally stay on per-provider URIs — there is no config row to record which URI the user registered. If wanted, an explicit env opt-in (e.g. OAUTH_UNIFIED_REDIRECT=1) could follow as a small separate PR.
  • Docs that walk through Google OAuth app setup may want a note that new configs need only the single URI — happy to add if you point me at the right page.

…lback

Consolidate the per-provider OAuth callback into a single
GET /v1/apps/callback, dispatching on the provider committed into the
HMAC-signed state at /authorize. Connecting Gmail, Drive, Calendar, and
the rest of the Google family against one OAuth app now needs one
registered redirect URI instead of one per service.

Rollout is opt-in per config row so existing users keep working:
- Saving a project app config stamps `redirectStyle: "unified"` into its
  settings - the form displays the unified URI at that moment, and the
  save disconnects the provider's connections anyway, so no live flow
  straddles the switch.
- /authorize sends the unified redirect URI (and scopes the state cookie
  to it) only for stamped rows. Unstamped rows, env-default credentials,
  and org-scoped configs keep the per-provider URI their OAuth app
  already has registered.
- The per-provider callback route stays untouched for those flows; both
  routes share one handler so the token exchange always repeats the
  exact redirect URI its authorization request used.

The unified route fails flat with a 400 when the state does not verify:
without a trustworthy provider there is no per-provider error page to
redirect to, and nothing an attacker-forged state can steer.

Closes onecli#301
@biztex biztex changed the title feat(api): one OAuth redirect URI for all providers via a unified cal… feat(api): one OAuth redirect URI for all providers via a unified callback Jul 30, 2026
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.

Too many redirect URIs to configure to connect to Google services

1 participant