Skip to content

[codex] Move OAuth org selector into state#1147

Merged
RhysSullivan merged 1 commit into
mainfrom
codex/oauth-org-state
Jun 26, 2026
Merged

[codex] Move OAuth org selector into state#1147
RhysSullivan merged 1 commit into
mainfrom
codex/oauth-org-state

Conversation

@RhysSullivan

Copy link
Copy Markdown
Collaborator

Summary

Moves URL-selected organization routing for OAuth callbacks out of the provider-facing redirect_uri query string and into OAuth state.

The callback URL registered with providers and sent on authorization requests is now static. When a cloud request is scoped by URL org, the SDK wraps the raw OAuth session state with the org slug for the authorization server to echo. Cloud callback middleware decodes that state, sets the internal org selector header, and rewrites the callback request back to the raw session state before completion.

Root Cause

The previous fix preserved org scope by appending executor_org to /api/oauth/callback. That works with tolerant providers, but it changes the callback URI shape and conflicts with providers or app registrations that reject query strings or require exact redirect URI registration.

Watch It

OAuth callback · state-scoped org survives a callback while the session cookie points elsewhere (cloud)

In the recording, the browser lands in one org, switches the session cookie to another org, starts OAuth from the original org URL, and completes successfully. The scenario asserts the provider-facing redirect_uri has no query string and the callback has no executor_org query param.

Validation

  • bun run --cwd packages/core/sdk test src/oauth-callback-state.test.ts src/oauth-flow.test.ts
  • bun run --cwd packages/core/api test src/server/oauth-origin.test.ts
  • bun run --cwd packages/react test src/plugins/oauth-sign-in.test.ts
  • bunx oxlint -c .oxlintrc.jsonc --deny-warnings <touched files>
  • bun run --cwd packages/core/sdk typecheck
  • bun run --cwd packages/core/api typecheck
  • bun run --cwd packages/react typecheck
  • bun run --cwd apps/cloud typecheck
  • bun run --cwd e2e typecheck
  • E2E_CLOUD_URL=http://localhost:43870 bun run --cwd e2e test:cloud cloud/oauth-callback-org-scope.test.ts

@RhysSullivan RhysSullivan marked this pull request as ready for review June 26, 2026 15:21
@RhysSullivan RhysSullivan merged commit 1b5ee77 into main Jun 26, 2026
11 of 13 checks passed
@github-actions

github-actions Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Cloudflare preview

Torn down — the PR is closed.

@greptile-apps

greptile-apps Bot commented Jun 26, 2026

Copy link
Copy Markdown

Greptile Summary

This PR moves org-scoped OAuth routing from the provider-facing redirect_uri query string into the OAuth state parameter, making the callback URL static and compatible with providers that require exact redirect URI registration.

  • The SDK's oauth.start now wraps the random session state and the URL-selected org slug together as base64url-encoded JSON before sending it to the authorization server. The raw state is still what gets persisted in oauth_session. The cloud callback middleware (start.ts) decodes the wrapped state, rewrites the URL's state param back to the raw value, and sets the org selector header before forwarding to the app handler.
  • buildOAuthRedirectUri in scoped-executor.ts no longer appends executor_org and is now unconditionally static. The oauthCallbackUrl helper in the React plugin is similarly simplified.
  • Unit tests in oauth-callback-state.test.ts and oauth-flow.test.ts, plus the updated e2e scenario, provide solid coverage of the new encode/decode path and the full authorization code flow under org switching.

Confidence Score: 4/5

Safe to merge. The OAuth callback flow is well-tested end-to-end and the cloud middleware rewrite is straightforward. One minor schema hardening opportunity exists in the new encode/decode layer.

The core mechanic (encode orgSlug into state on start, decode and rewrite on callback) is correctly implemented and covered by unit, integration, and e2e tests. The one notable gap is that OAuthCallbackStateSchema uses Schema.String for the state field, so a crafted payload with an empty state string passes decode and propagates to the DB lookup as an empty key, yielding a confusing session-not-found error rather than a clean rejection at the decode boundary. This is not exploitable and causes no data corruption, but Schema.NonEmptyString would make the boundary more defensive.

packages/core/sdk/src/oauth.ts — the OAuthCallbackStateSchema field types

Important Files Changed

Filename Overview
packages/core/sdk/src/oauth.ts Introduces encodeOAuthCallbackState/decodeOAuthCallbackState using base64url-wrapped JSON. The OAuthCallbackStateSchema uses Schema.String for the state field which permits empty string; a crafted state with state:"" would cause a session-not-found error at completion instead of a clear decode failure.
apps/cloud/src/start.ts Replaces executor_org query param extraction with decodeOAuthCallbackState on the incoming state param, rewrites state to the raw value, and sets ORG_SELECTOR_HEADER. Logic is correct for the GET callback path.
packages/core/sdk/src/oauth-service.ts Adds encodeOAuthCallbackState wrapping during oauth.start, sending the encoded providerState to the authorization server while persisting only the raw state in the DB. The complete path is unchanged and still receives the raw state via middleware rewriting.
packages/core/api/src/server/scoped-executor.ts Removes orgSlug from buildOAuthRedirectUri and instead passes oauthCallbackStateOrgSlug to the executor config. Clean separation: static redirect_uri, org scope in state.
packages/react/src/plugins/oauth-sign-in.tsx Simplifies oauthCallbackUrl to always return a static URL without org query params. getActiveOrgSlug and OAUTH_CALLBACK_ORG_QUERY_PARAM usages removed correctly.
packages/core/sdk/src/oauth-callback-state.test.ts New test file covering the encode/decode round-trip, no-orgSlug passthrough, and rejection of foreign state values. Good coverage for the core new logic.
e2e/cloud/oauth-callback-org-scope.test.ts Updated e2e scenario now asserts redirect_uri has no query string and the callback URL has no executor_org param, matching the new state-scoped design.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Browser
    participant CloudMiddleware as Cloud Middleware (start.ts)
    participant OAuthService as OAuth Service
    participant DB as oauth_session DB
    participant Provider as OAuth Provider

    Browser->>OAuthService: "oauth.start({ ... })"
    Note over OAuthService: createOAuthState() → raw state
    Note over OAuthService: encodeOAuthCallbackState({ state: raw, orgSlug })
    Note over OAuthService: providerState = base64url({ state, orgSlug })
    OAuthService->>DB: store session with raw state
    OAuthService-->>Browser: "{ authorizationUrl, state: raw }"
    Note over Browser: authorizationUrl has state=providerState (encoded)
    Note over Browser: redirect_uri has NO org query param

    Browser->>Provider: "GET /authorize?state=providerState&redirect_uri=static"
    Provider-->>Browser: "Redirect to /api/oauth/callback?code=X&state=providerState"

    Browser->>CloudMiddleware: "GET /api/oauth/callback?code=X&state=providerState"
    Note over CloudMiddleware: oauthCallbackOrgScopedRequest decodes state
    Note over CloudMiddleware: url.searchParams.set(state, rawState)
    Note over CloudMiddleware: headers.set(ORG_SELECTOR_HEADER, orgSlug)
    CloudMiddleware->>OAuthService: "GET /api/oauth/callback?code=X&state=rawState + org header"
    OAuthService->>DB: "findFirst oauth_session WHERE state = rawState"
    DB-->>OAuthService: session row
    OAuthService-->>Browser: Connection created
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Browser
    participant CloudMiddleware as Cloud Middleware (start.ts)
    participant OAuthService as OAuth Service
    participant DB as oauth_session DB
    participant Provider as OAuth Provider

    Browser->>OAuthService: "oauth.start({ ... })"
    Note over OAuthService: createOAuthState() → raw state
    Note over OAuthService: encodeOAuthCallbackState({ state: raw, orgSlug })
    Note over OAuthService: providerState = base64url({ state, orgSlug })
    OAuthService->>DB: store session with raw state
    OAuthService-->>Browser: "{ authorizationUrl, state: raw }"
    Note over Browser: authorizationUrl has state=providerState (encoded)
    Note over Browser: redirect_uri has NO org query param

    Browser->>Provider: "GET /authorize?state=providerState&redirect_uri=static"
    Provider-->>Browser: "Redirect to /api/oauth/callback?code=X&state=providerState"

    Browser->>CloudMiddleware: "GET /api/oauth/callback?code=X&state=providerState"
    Note over CloudMiddleware: oauthCallbackOrgScopedRequest decodes state
    Note over CloudMiddleware: url.searchParams.set(state, rawState)
    Note over CloudMiddleware: headers.set(ORG_SELECTOR_HEADER, orgSlug)
    CloudMiddleware->>OAuthService: "GET /api/oauth/callback?code=X&state=rawState + org header"
    OAuthService->>DB: "findFirst oauth_session WHERE state = rawState"
    DB-->>OAuthService: session row
    OAuthService-->>Browser: Connection created
Loading

Reviews (1): Last reviewed commit: "Move OAuth org selector into state" | Re-trigger Greptile

Comment on lines +41 to +44
const OAuthCallbackStateSchema = Schema.Struct({
state: Schema.String,
orgSlug: Schema.String,
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 The state field in OAuthCallbackStateSchema uses Schema.String, which permits an empty string. If someone crafts a base64url payload with {"state":"","orgSlug":"acme"}, decodeOAuthCallbackState returns { state: "", orgSlug: "acme" }, and the cloud middleware then rewrites the callback URL to ?state=. The downstream oauth.complete call will always fail with OAuthSessionNotFoundError for a non-existent empty-string key, rather than being rejected at the decode boundary where it belongs. Schema.NonEmptyString closes this gap and keeps the invalid-state error local to the decode step.

Suggested change
const OAuthCallbackStateSchema = Schema.Struct({
state: Schema.String,
orgSlug: Schema.String,
});
const OAuthCallbackStateSchema = Schema.Struct({
state: Schema.NonEmptyString,
orgSlug: Schema.NonEmptyString,
});

RhysSullivan added a commit that referenced this pull request Jun 28, 2026
Org-scoped client-id metadata documents registered their callback with an
executor_org query param on redirect_uri, but the client sends the bare
callback and the org is carried in the OAuth state (#1147). Providers that
exact-match redirect_uri (PostHog) rejected the authorize request with
"Mismatching redirect URI". Org targets keep their distinct client_id URL
but now register the same bare callback redirect_uri as every other target.
RhysSullivan added a commit that referenced this pull request Jun 29, 2026
)

Org-scoped client-id metadata documents registered their callback with an
executor_org query param on redirect_uri, but the client sends the bare
callback and the org is carried in the OAuth state (#1147). Providers that
exact-match redirect_uri (PostHog) rejected the authorize request with
"Mismatching redirect URI". Org targets keep their distinct client_id URL
but now register the same bare callback redirect_uri as every other target.
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