Skip to content

feat(oauth): per-org oauth_redirect_url + opt-in white-label switch#398

Merged
angel-manuel merged 2 commits into
devfrom
feat/org-oauth-redirect-url
Jun 14, 2026
Merged

feat(oauth): per-org oauth_redirect_url + opt-in white-label switch#398
angel-manuel merged 2 commits into
devfrom
feat/org-oauth-redirect-url

Conversation

@angel-manuel

Copy link
Copy Markdown
Contributor

Context

White-label integrations (consumer: overfolder) currently pick the OAuth provider redirect_uri per request by passing a redirect_uri string, gated by a per-org allow-list (oauth_callback_allowed_hosts, #388/#392). A white-label org has exactly one partner-hosted callback, so an admin-set per-org URL is sufficient — the caller never needs to send (or host-validate) a URL on every request.

This collapses both knobs into:

  1. A single admin-set per-org oauth_redirect_url (the partner callback), managed in Org Settings.
  2. A per-request bool use_org_redirect that opts a flow into that org URL.

Why a per-request opt-in (not a global per-org rule)

A global override would break overslash's own dashboard connect flows ("Try it"/Connect/reconnect/upgrade-scopes), which rely on the default /v1/oauth/callback so the browser completes the flow in place. Those flows send no redirect/white-label fields, so an opt-in bool (default = overslash callback) leaves them untouched in white-label orgs. Only the partner sets use_org_redirect: true.

The flow-row contract is unchanged: flow.redirect_uri = Some(org_url) ⇒ white-label ⇒ complete via POST /v1/oauth/exchange; NULL ⇒ default ⇒ browser GET /v1/oauth/callback. /v1/oauth/exchange, include_raw/wrap-raw, and the default callback path are all kept verbatim.

Breaking change to the white-label API (acceptable per scoping). Overfolder-side changes (switching from connect_redirect_uri: <url> to connect_use_org_redirect: true + setting the URL in Org Settings) are out of scope — this PR is overslash-only.

Changes

Backend

  • Migration 081: add orgs.oauth_redirect_url, drop oauth_callback_allowed_hosts.
  • Kernel (platform_connections.rs): use_org_redirect resolves the org URL (400 if unconfigured/invalid) else the default callback; parse_redirect_uri simplified; callback_host_allowed removed.
  • Drop per-request redirect_uri on POST /v1/connections & /upgrade_scopes, and connect_redirect_uri on MCP create_service — now use_org_redirect / connect_use_org_redirect.
  • Settings API: GET/PATCH /v1/orgs/{id}/oauth-redirect-settings (admin-only, audited, URL validated on write via the shared parse_redirect_uri).

Dashboard

  • Org Settings: the "OAuth callback hosts" list becomes a single OAuth redirect URL input. The dashboard's own Connect flows still use the default callback.

Testing

  • oauth_org_redirect_url.rs (rewritten, 13 tests): org-url-as-redirect, use_org_redirect with missing URL → 400, default-callback fallback (no flag), exchange/callback guards, white-label upgrade-scopes, and the settings API (round-trip, clear, invalid-URL 400, admin-only).
  • Kernel unit tests (15) green; cargo clippy clean; dashboard svelte-check 0/0 and strict build pass.
  • vet (agentic) reviewed the diff: no issues.

🤖 Generated with Claude Code

Replace the per-request white-label `redirect_uri` override and the
`oauth_callback_allowed_hosts` allow-list with a single admin-set per-org
`oauth_redirect_url`, opted into per request via a `use_org_redirect` bool.

Why a per-request opt-in (not a global per-org rule): a global override would
break overslash's own dashboard connect flows ("Try it"/Connect/reconnect/
upgrade-scopes), which rely on the default `/v1/oauth/callback` so the browser
completes the flow in place. The bool defaults to false, so those flows are
untouched in white-label orgs; only the partner sets `use_org_redirect: true`.

Backend:
- migration 081: add `orgs.oauth_redirect_url`, drop `oauth_callback_allowed_hosts`
- kernel: `use_org_redirect` resolves the org URL (400 if unconfigured), else the
  default callback; flow-row contract unchanged (Some -> exchange, NULL -> callback)
- drop the per-request `redirect_uri` fields on `POST /v1/connections`,
  `/upgrade_scopes`, and MCP `create_service` (now `use_org_redirect` /
  `connect_use_org_redirect`); remove `callback_host_allowed`
- settings API: `GET/PATCH /v1/orgs/{id}/oauth-redirect-settings` (admin, audited,
  URL validated on write via the shared `parse_redirect_uri`)
- keep `/v1/oauth/exchange`, `include_raw`/wrap-raw, default callback path

Dashboard:
- org settings: replace the OAuth callback hosts list with a single OAuth
  redirect URL input; the dashboard's own Connect flows still use the default
  callback

Tests: rewrite as `oauth_org_redirect_url.rs` (org-url-as-redirect, missing-URL
400, default-callback fallback, exchange/guard, upgrade-scopes, settings API).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Jun 13, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
overslash Ready Ready Preview, Comment Jun 13, 2026 10:50pm

Request Review

Rename the stale 'OAuth callback hosts' screenshot script/helper to the new
single 'OAuth redirect URL' card (setOauthRedirectUrl → oauth-redirect-settings).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@codecov

codecov Bot commented Jun 13, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@angel-manuel

Copy link
Copy Markdown
Contributor Author

Dashboard screenshot

Org Settings → OAuth redirect URL card, captured against the real e2e stack (make e2e-up + screenshot-org-oauth-redirect-url.mjs) with the org's white-label URL seeded via the live API:

The single URL input replaces the former "OAuth callback hosts" allow-list. The dashboard's own Connect flows are unchanged — they never opt into use_org_redirect, so they keep using the default Overslash callback even in white-label orgs.

@angel-manuel angel-manuel merged commit 210a2fc into dev Jun 14, 2026
15 checks passed
@angel-manuel angel-manuel deleted the feat/org-oauth-redirect-url branch June 14, 2026 08:50
angel-manuel added a commit that referenced this pull request Jun 15, 2026
…ctions/import) (#400)

* feat(oauth): white-label connections as a token vault (POST /v1/connections/import)

Partners that already own their OAuth (e.g. Overfolder) no longer route the
dance through overslash. They run authorize + code-exchange themselves and
`POST /v1/connections/import` the resulting tokens; overslash stores the
connection (identical row to an orchestrated callback), refreshes it, and
injects it at execution — and issues no `redirect_uri`. This dissolves the
per-provider redirect problem entirely (overslash issues no redirect URI).

Refresh mode is fixed per import:
- self-refresh: a pinned `byoc_credential_id` (validated at import — a bad id
  400s here, not at first refresh); overslash refreshes via the refresh-token
  grant, hard-pinned to that client (never the cascade).
- integration-managed: a null `byoc_credential_id` flags
  `connections.integration_managed`. Overslash injects the stored token until
  expiry, then surfaces `reauth_required` marked integration-managed with NO
  reconnect link (auth_url omitted, provider + integration_managed: true) and
  fires a `connection.refresh_required` webhook — the partner refreshes and
  re-imports. It never refreshes and never borrows the env/org
  `OAUTH_*_CLIENT` cascade (a refresh token is valid only against its issuing
  client). Also covers opaque bearer tokens / PATs with no client at all.

Re-import is idempotent, keyed on (identity, provider, account_email) — it
updates the row in place rather than accreting duplicates each refresh cycle;
a distinct account_email vaults a second account.

Removed (now obsolete / dead once no flow can set a custom redirect):
- all of #398: `orgs.oauth_redirect_url`, the `use_org_redirect` switch, the
  `/v1/orgs/{id}/oauth-redirect-settings` endpoints, the dashboard section.
- `POST /v1/oauth/exchange` + the callback custom-redirect guard.
- `include_raw` / the raw authorize-URL surface on `POST /v1/connections`,
  `/upgrade_scopes`, MCP `create_service`, the `raw` envelope fields, and the
  MCP chat-delivery strip — partners build their own authorize URLs now.
- `oauth_connection_flows.redirect_uri`.

Migration `082_connection_token_vault` adds `connections.integration_managed`
and drops `orgs.oauth_redirect_url` + `oauth_connection_flows.redirect_uri`.

Dashboard: connection detail surfaces the integration-managed credential
source; org settings drops the OAuth redirect URL card.

Tests: new `connection_import.rs` drives a fake integration partner through
every path (integration-managed inject-then-reauth via a mock upstream,
self-refresh BYOC validation, idempotent/multi-account re-import, expiry
resolution, on_behalf_of binding, input validation). Reauth/oauth_x/
services-auto-connect suites updated for the no-`raw` envelope shape.

Design: docs/design/white-label-token-vault.md; SPEC §7; DECISIONS D20.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(oauth): reject refresh-mode changes on connection re-import

Seer review (HIGH): on re-import, a supplied `byoc_credential_id` that would
change a connection's refresh mode was validated and then silently discarded
(the update path only writes tokens/scopes), so a caller expecting self-refresh
on an integration-managed row got a misleading 200 with the old mode.

The mode is fixed at first import by design. Make that contract explicit:
- omitting `byoc_credential_id` stays a token-only update preserving the mode
  (the hot path for integration-managed re-import);
- supplying a `byoc_credential_id` that would flip the mode
  (integration-managed → self-refresh) or re-pin to a different client now
  returns 400 with a clear "delete and re-import to change" message.

Adds a regression test (integration-managed → self-refresh re-import → 400) and
documents the behavior in the design doc.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(oauth): make import match mode-aware so it never overwrites orchestrated connections

Seer review (HIGH): an emailless import resolves the re-import target via the
`(identity, provider)` fallback in `find_for_import`, which matches on NULL
account_email. An orchestrated connection whose userinfo fetch left
account_email NULL could therefore be matched and have its tokens overwritten —
and the previous mode guard only fired when `byoc_credential_id` was supplied,
so an integration-managed import slipped through.

Make the match mode-aware:
- email-keyed match (caller named the account): an in-place update is intended,
  so a mode/client change is rejected with 400 (the prior fix, generalized).
- emailless heuristic match: reuse the row ONLY when it is the same kind of
  vault connection (same mode + same pinned client). On a mismatch — notably an
  orchestrated connection — fall through to creating a fresh row instead of
  clobbering it.

Adds a regression test (emailless integration-managed import leaves a NULL-email
orchestrated connection untouched and creates a separate row) and documents the
match semantics in the design doc.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(oauth): preserve token_expires_at on a token-only connection re-import

Seer review (MEDIUM): a re-import that carries no fresh `expires_at`/`expires_in`
passed `None` to `update_tokens_and_scopes`, nulling `token_expires_at`. For an
integration-managed connection that makes it look perpetually valid — it would
never surface `reauth_required` and would keep injecting a token that has
actually expired upstream.

Fix surgically in the import kernel (not the shared repo fn the orchestrated
upgrade callback also uses): on a re-import, fall back to the existing
`token_expires_at` when the caller supplies no fresh expiry; a supplied expiry
still overrides it. Adds a regression test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(oauth): preserve existing scopes on a token-only connection re-import

Seer review (CRITICAL): `ImportConnectionInput.scopes` defaults to `[]`, and the
update path overwrote `scopes` unconditionally — so a token-only re-import that
omitted `scopes` wiped the connection's granted scopes, 403ing every subsequent
scope-gated action call.

Mirror the expiry fix: on a re-import, preserve the existing scopes when the
caller supplies none; a non-empty `scopes` still overrides. The effective scope
set now also flows to the response, audit detail, and webhook payload. Adds a
regression test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(oauth): skip upgrade-URL mint for integration-managed missing_scopes

Seer review (LOW): when an action needs a scope an integration-managed
connection lacks, `check_required_scopes` called `mint_upgrade_auth_url` — which
an integration-managed connection can't use (Overslash holds no client), doing
wasted work and leaving a stray `oauth_connection_flows` row.

Guard it: for integration-managed connections, return `missing_scopes` with
`auth_url`/`short` omitted and no mint — the integration broadens the grant and
re-imports. Adds a regression test (403 missing_scopes, no auth_url, zero flow
rows created).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(oauth): reject /upgrade_scopes for integration-managed connections

Seer review (MEDIUM): `POST /v1/connections/{id}/upgrade_scopes` didn't guard
against integration-managed connections, so it would call kernel_create_connection
and mint an orchestrated OAuth flow (incorrect when a fallback client exists, a
generic 400 when not). The companion `check_required_scopes` fix already skips
the mint for these connections, but the REST endpoint — which the missing_scopes
`upgrade_url` points at — needed the same guard.

Guard the handler: an integration-managed connection returns a clear 400
directing the caller to broaden the grant and re-import via
POST /v1/connections/import. Adds a regression test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(oauth): nullable connection scopes — unknown gets benefit of the doubt

`connections.scopes` was `NOT NULL DEFAULT '{}'`, so an import that omitted
`scopes` recorded an empty set — indistinguishable from "genuinely no scopes" —
and the action scope-gate then 403'd `missing_scopes` on a token that was
actually valid (Overslash can't know an imported token's real grants).

Make scopes nullable to represent "unknown" distinctly (migration
083_connection_scopes_nullable; `ConnectionRow.scopes: Option<Vec<String>>`):
- `POST /v1/connections/import` `scopes` now defaults to `null` (unknown), not
  `[]`. Re-import preserve-on-omit keeps the recorded set.
- The scope-gate (`check_required_scopes`) and the dashboard credential-health
  badge (`derive_credentials_status` via a new `ScopeKnowledge` enum) treat
  `null` as benefit of the doubt — covering everything — so unknown-scope
  imports aren't falsely 403'd; a real shortfall still surfaces upstream.
  Orchestrated flows always record the concrete granted set, so they keep
  precise gating.
- The `missing_scopes` envelope now reports both `required` (the action's full
  set) and `missing` (the delta), so a caller sees the target, not just the gap.

Tests: import-without-scopes → recorded null + action executes (benefit of the
doubt); known-but-insufficient scopes → 403 with required+missing; re-import
preserves scopes; `ScopeKnowledge::Unknown` classifies Ok.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Factory <factory@overslash.dev>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
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.

1 participant