[codex] Move OAuth org selector into state#1147
Conversation
Cloudflare previewTorn down — the PR is closed. |
Greptile SummaryThis PR moves org-scoped OAuth routing from the provider-facing
Confidence Score: 4/5Safe 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
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
%%{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
Reviews (1): Last reviewed commit: "Move OAuth org selector into state" | Re-trigger Greptile |
| const OAuthCallbackStateSchema = Schema.Struct({ | ||
| state: Schema.String, | ||
| orgSlug: Schema.String, | ||
| }); |
There was a problem hiding this comment.
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.
| const OAuthCallbackStateSchema = Schema.Struct({ | |
| state: Schema.String, | |
| orgSlug: Schema.String, | |
| }); | |
| const OAuthCallbackStateSchema = Schema.Struct({ | |
| state: Schema.NonEmptyString, | |
| orgSlug: Schema.NonEmptyString, | |
| }); |
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.
) 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.
Summary
Moves URL-selected organization routing for OAuth callbacks out of the provider-facing
redirect_uriquery string and into OAuthstate.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_orgto/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
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_urihas no query string and the callback has noexecutor_orgquery param.Validation
bun run --cwd packages/core/sdk test src/oauth-callback-state.test.ts src/oauth-flow.test.tsbun run --cwd packages/core/api test src/server/oauth-origin.test.tsbun run --cwd packages/react test src/plugins/oauth-sign-in.test.tsbunx oxlint -c .oxlintrc.jsonc --deny-warnings <touched files>bun run --cwd packages/core/sdk typecheckbun run --cwd packages/core/api typecheckbun run --cwd packages/react typecheckbun run --cwd apps/cloud typecheckbun run --cwd e2e typecheckE2E_CLOUD_URL=http://localhost:43870 bun run --cwd e2e test:cloud cloud/oauth-callback-org-scope.test.ts