fix(oauth): stop treating a foreign window.opener as a bind flow - #6425
Conversation
The /oauth/:provider callback decided between an account bind and a plain login with `window.opener ? 'bind' : 'login'`. Any tab opened from an external link (target="_blank", Slack, mail clients, another site) carries a live opener, and that opener survives the cross-origin round trip to the identity provider. Such a login callback was therefore misread as a bind: it posted a handshake to a window that speaks no such protocol, showed the "binding your account" screen, and hung until the 30s deadline fired with "OAuth binding timed out" — while the backend was never called at all. Reproduced against a real Keycloak round trip: a tab opened via window.open still reports window.opener !== null on the callback, so mode resolved to 'bind' for an ordinary OIDC login. A bind now requires positive proof: the popup we open for it is same-origin (about:blank) before being sent to the provider, so we stamp its own sessionStorage. The stamp rides through the provider round trip and is scoped to that popup alone, so a login tab can never carry it. Ambiguity resolves to 'login', which is the recoverable direction. Affects every provider sharing this callback (OIDC, GitHub, Discord, LinuxDO, custom). Co-Authored-By: Claude <noreply@anthropic.com>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (4)
🚧 Files skipped from review as they are similar to previous changes (1)
WalkthroughOAuth bind popups now record provider-and-state markers in ChangesOAuth callback mode
Estimated code review effort: 3 (Moderate) | ~20 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (2)
web/src/features/auth/lib/oauth-callback-mode.test.ts (2)
27-35: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAdd an explicit return type to
fakeStorage.This new helper annotates its parameter but not its return value. Use
OAuthModeStorage & { snapshot: () => Record<string, string> }(with a type-only import) to satisfy the repository’s TypeScript typing rule.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@web/src/features/auth/lib/oauth-callback-mode.test.ts` around lines 27 - 35, Update fakeStorage with an explicit return type of OAuthModeStorage & { snapshot: () => Record<string, string> }, adding a type-only import for OAuthModeStorage from the existing auth storage module.Source: Coding guidelines
96-100: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winCover the storage-exception fallback.
The current test only covers
storage: null; add a fake whosegetItemthrows to verify the resolver returnsloginwhen storage access fails.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@web/src/features/auth/lib/oauth-callback-mode.test.ts` around lines 96 - 100, Extend the `resolveOAuthCallbackMode` test for missing storage with a fake storage object whose `getItem` method throws, and assert the resolver returns `login` rather than propagating the exception. Keep the existing `storage: null` case intact.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@web/src/routes/oauth/`$provider.tsx:
- Around line 72-78: Guard the sessionStorage property reads before invoking the
OAuth helpers: in web/src/routes/oauth/$provider.tsx lines 72-78, update the
resolveOAuthCallbackMode call to safely handle a thrown window.sessionStorage
access and use the intended login-safe fallback; in
web/src/features/profile/components/tabs/account-bindings-tab.tsx lines 166-169,
apply the same protection to the popup.sessionStorage read passed to
markOAuthBindPopup. Ensure blocked storage does not escape from either property
access.
---
Nitpick comments:
In `@web/src/features/auth/lib/oauth-callback-mode.test.ts`:
- Around line 27-35: Update fakeStorage with an explicit return type of
OAuthModeStorage & { snapshot: () => Record<string, string> }, adding a
type-only import for OAuthModeStorage from the existing auth storage module.
- Around line 96-100: Extend the `resolveOAuthCallbackMode` test for missing
storage with a fake storage object whose `getItem` method throws, and assert the
resolver returns `login` rather than propagating the exception. Keep the
existing `storage: null` case intact.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: fb93aa4d-f517-4e19-aa1f-f41bc6fedfdb
📒 Files selected for processing (4)
web/src/features/auth/lib/oauth-callback-mode.test.tsweb/src/features/auth/lib/oauth-callback-mode.tsweb/src/features/profile/components/tabs/account-bindings-tab.tsxweb/src/routes/oauth/$provider.tsx
|
We’re about to squash and merge this PR. Which identity would you prefer for the squashed commit: |
Upstream 2026-07-31..2026-08-01: deepseek responses api (QuantumNous#6562), zstd request decompression (QuantumNous#6545), log stream status (QuantumNous#6558), tiered retry billing settlement (QuantumNous#6518, QuantumNous#6570), OAuth opener fix (QuantumNous#6425), multipart image edit fix (QuantumNous#6559), public header nav style (QuantumNous#6557). Conflict resolution: - controller/relay.go: keep per-channel rate limit check (custom) and upstream PrepareTieredBillingForSelectedGroup call - relay/common/relay_info.go: keep RequestDebugSnapshot (custom) and upstream TieredBillingSnapshot comment
…ntumNous#6425) * fix(oauth): stop treating a foreign window.opener as a bind flow The /oauth/:provider callback decided between an account bind and a plain login with `window.opener ? 'bind' : 'login'`. Any tab opened from an external link (target="_blank", Slack, mail clients, another site) carries a live opener, and that opener survives the cross-origin round trip to the identity provider. Such a login callback was therefore misread as a bind: it posted a handshake to a window that speaks no such protocol, showed the "binding your account" screen, and hung until the 30s deadline fired with "OAuth binding timed out" — while the backend was never called at all. Reproduced against a real Keycloak round trip: a tab opened via window.open still reports window.opener !== null on the callback, so mode resolved to 'bind' for an ordinary OIDC login. A bind now requires positive proof: the popup we open for it is same-origin (about:blank) before being sent to the provider, so we stamp its own sessionStorage. The stamp rides through the provider round trip and is scoped to that popup alone, so a login tab can never carry it. Ambiguity resolves to 'login', which is the recoverable direction. Affects every provider sharing this callback (OIDC, GitHub, Discord, LinuxDO, custom). * fix(oauth): harden bind popup detection
合并上游 16 个提交,主要是 token Auto 分组 (QuantumNous#6590)、deepseek responses API (QuantumNous#6562)、Bedrock 客户端断开取消 (QuantumNous#6589)、分层重试计费加固 (QuantumNous#6518/QuantumNous#6570)、 zstd 请求解压 (QuantumNous#6545)、OIDC 自定义显示名 (QuantumNous#6012)、日志暴露 stream_status (QuantumNous#6558)。 33 处冲突的处理: - 11 个 legacy channel adaptor:上游删除 panic 之后的死代码以配合新增的 go vet CI,本仓早已把整段 panic 换成返回 ErrLegacyAdaptorNotImplemented, 已达成同一目的且不会让中继链路 panic,保留本仓实现。 - relay-aws.go 流式循环:两侧改动正交,合并保留 —— 上游的 ctx.Done() 取消 分支(客户端断开时不再空转上游),加本仓的 finalizeClaudeOnError(提前 返回时补发流终止符,否则 Claude 格式调用方会一直挂着)。 - 7 个 locale:两侧各自插入相邻 key,按字母序归并;上游 24 个新 key 与本仓 1278 个 fork key 全部保留,逐一核对无丢失。 - keys / oauth / profile 前端:上游 Auto 分组为主体,叠加本仓改动; account-bindings-tab 与 oauth/$provider 保留本仓实现,理由见下。 测试取舍: - 不引入 model/token_auto_groups_cache_test.go:它依赖上游的 truncateTables, 该助手全局清表,与本仓行级清理原则冲突(共享库里有开发数据)。 - 不携带上游 controller/token_test.go:其迁移兼容测试会替换 model.DB 并在 cleanup 里关闭,后续用 harness 连接的测试会拿到已关闭的句柄。按既有约定, token_auto_groups_test.go 需要的四个助手放进 zz_upstream_test_shims_test.go, 其中 openTokenControllerTestDB 增加了 model.DB/LOG_DB 的成对保存还原。 本仓原 token_test.go 的 controller 行为测试迁到 gen_ctrl_token_handlers_test.go,与上游文件名脱钩以免再冲突。 - 三处断言随上游行为更新:stream_status 现对日志所有者可见;deepseek 的 ConvertOpenAIResponsesRequest 已实现不再返回错误;计费路径判定收紧为 「标记之外还需带对应 usage payload」,并补了三个反向用例锁住该语义。 遗留:oauth 绑定回调仍用 window.opener 判定 bind/login,上游 QuantumNous#6425 已改为 sessionStorage 标记 + state 比对。该修复要求 popup 先以 about:blank 打开再打 标记,本仓四个内置 provider 走 window.open(url) 直开,直接套用会让绑定永远 判成 login,故本次未采纳,需单独改造。
合并官方上游 Calcium-Ion/new-api main 分支的 10 个新 commit: - Feat/auto group (QuantumNous#6590) - fix(aws): cancel Bedrock requests on client disconnect (QuantumNous#6589) - fix(billing): harden tiered retry group-switch billing (QuantumNous#6570) - fix(billing): settle tiered retries with final group (QuantumNous#6518) - feat: deepseek responses api (QuantumNous#6562) - fix(oauth): stop treating a foreign window.opener as a bind flow (QuantumNous#6425) - fix(relay): preserve multipart image edits for New API channels (QuantumNous#6559) - feat(logs): expose stream status to log owners (QuantumNous#6558) - feat: support zstd request decompression (QuantumNous#6545) - style: use text-sm for public header nav links (QuantumNous#6557) 冲突解决: model/option.go 的 switch case 区域,双方各自新增了选项注册 (我方 GroupPassThrough + 官方 MaxTokenAutoGroups),取并集保留两者。 其余文件均自动合并成功。 验证: go build + go test 全过(relay/helper、middleware、controller)
Upstream v1.0.0-rc.23. Conflict resolutions: - service/group.go: take upstream's IsUserSelectableGroup and its original comment; our "User Group" label survives untouched. - OAuth callback files: our fix landed upstream as QuantumNous#6425 in a stronger form (bind mark tied to the OAuth state, storage access that survives blocked sessionStorage, Telegram bind callbacks). Take upstream wholesale and drop our colocated oauth-callback-mode.test.ts, superseded by lib/__tests__/. pt-BR follow-up for the new upstream strings: 24 frontend keys (Auto group ordering, OIDC display name) and 3 backend keys (token.auto_groups_*). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018ZsKS6An5YHvZTW3cpVTNX
* v1.0.0-rc.24: (117 commits) CI: enhance release synchronization workflow with optional file syncing fix: 修复兑换码额度精度损失 (QuantumNous#6685) feat(rate-limit): add user critical rate limit middleware for access token and aff transfer routes fix: test Claude/Gemini endpoints with native request format (QuantumNous#6698) feat(channels): refine fetched model categorization (QuantumNous#6632) Merge commit from fork refactor(relay): move replay metadata onto request bodies fix(relay): set Request.GetBody so the HTTP/2 transport can transparently retry after an upstream stream reset (QuantumNous#6249) Feat/auto group (QuantumNous#6590) fix(aws): cancel Bedrock requests on client disconnect (QuantumNous#6589) fix(billing): harden tiered retry group-switch billing (QuantumNous#6570) fix(billing): settle tiered retries with final group (QuantumNous#6518) feat: deepseek responses api (QuantumNous#6562) style: use text-sm for public header nav links to match other nav components (QuantumNous#6557) fix(oauth): stop treating a foreign window.opener as a bind flow (QuantumNous#6425) fix(relay): preserve multipart image edits for New API channels (QuantumNous#6559) feat(logs): expose stream status to log owners (QuantumNous#6558) feat: support zstd request decompression (QuantumNous#6545) fix: preserve Qwen thinking_budget passthrough (QuantumNous#5836) feat(oidc): 支持自定义 OIDC 登录显示名称 (QuantumNous#6012) ... # Conflicts: # service/text_quota.go # web/src/features/models/components/drawers/model-mutate-drawer.tsx # web/src/features/pricing/components/model-details.tsx # web/src/features/pricing/lib/price.ts
…ntumNous#6425) * fix(oauth): stop treating a foreign window.opener as a bind flow The /oauth/:provider callback decided between an account bind and a plain login with `window.opener ? 'bind' : 'login'`. Any tab opened from an external link (target="_blank", Slack, mail clients, another site) carries a live opener, and that opener survives the cross-origin round trip to the identity provider. Such a login callback was therefore misread as a bind: it posted a handshake to a window that speaks no such protocol, showed the "binding your account" screen, and hung until the 30s deadline fired with "OAuth binding timed out" — while the backend was never called at all. Reproduced against a real Keycloak round trip: a tab opened via window.open still reports window.opener !== null on the callback, so mode resolved to 'bind' for an ordinary OIDC login. A bind now requires positive proof: the popup we open for it is same-origin (about:blank) before being sent to the provider, so we stamp its own sessionStorage. The stamp rides through the provider round trip and is scoped to that popup alone, so a login tab can never carry it. Ambiguity resolves to 'login', which is the recoverable direction. Affects every provider sharing this callback (OIDC, GitHub, Discord, LinuxDO, custom). * fix(oauth): harden bind popup detection
…ntumNous#6425) * fix(oauth): stop treating a foreign window.opener as a bind flow The /oauth/:provider callback decided between an account bind and a plain login with `window.opener ? 'bind' : 'login'`. Any tab opened from an external link (target="_blank", Slack, mail clients, another site) carries a live opener, and that opener survives the cross-origin round trip to the identity provider. Such a login callback was therefore misread as a bind: it posted a handshake to a window that speaks no such protocol, showed the "binding your account" screen, and hung until the 30s deadline fired with "OAuth binding timed out" — while the backend was never called at all. Reproduced against a real Keycloak round trip: a tab opened via window.open still reports window.opener !== null on the callback, so mode resolved to 'bind' for an ordinary OIDC login. A bind now requires positive proof: the popup we open for it is same-origin (about:blank) before being sent to the provider, so we stamp its own sessionStorage. The stamp rides through the provider round trip and is scoped to that popup alone, so a login tab can never carry it. Ambiguity resolves to 'login', which is the recoverable direction. Affects every provider sharing this callback (OIDC, GitHub, Discord, LinuxDO, custom). * fix(oauth): harden bind popup detection
📝 变更描述 / Description
/oauth/:providerdecides between an account bind and a plain login with a single signal:window.openercannot carry that meaning. Any tab opened from an external link (target="_blank", Slack, Teams, mail clients, another site) has a live opener, and that opener survives the cross-origin round trip to the identity provider.So an ordinary login callback in such a tab is misread as a bind: it posts the bind handshake to a window that speaks no such protocol, renders the "binding your account" screen, and hangs until the 30s deadline in
startOAuthBindResponseDeadlinefires with "OAuth binding timed out". The backend is never called at all —GET /api/oauth/:provideris never issued.This is not browser-specific or user-specific: it hits every user who reaches the site through a link instead of typing the URL.
Root cause reproduced against a live Keycloak realm:
window.open(=target="_blank")window.openertruthy/oauth/oidc?code=…window.openerstill truthy,opener.closed === falsebind— wrong, this was a loginThe change
A bind now needs positive proof instead of an inference. The popup opened for a bind is same-origin (
about:blank) before it is sent to the provider, so it is stamped in its ownsessionStorage. The stamp survives the provider round trip and is scoped to that popup alone, so a login tab can never carry it.Resolution rules (
resolveOAuthCallbackMode): a bind requires both a live opener and our stamp for that exact provider. Everything else resolves tologin, deliberately — a login callback recovers on its own, whereas a wrongly assumed bind can only time out.The decision is extracted into
web/src/features/auth/lib/oauth-callback-mode.tsso it is unit-testable; the storage/opener dependencies are structural, matching the existing style inoauth-bind-window.ts.Affects every provider sharing this callback: OIDC, GitHub, Discord, LinuxDO and custom providers.
🚀 变更类型 / Type of change
🔗 关联任务 / Related Issue
✅ 提交前检查项 / Checklist
📸 运行证明 / Proof of Work
Reproduction of the root cause (real Keycloak round trip, browser console):
Stamp behaves as required (same browser, same round trip):
End-to-end against a build of this branch — the previously hanging scenario (tab with a foreign opener → OIDC callback) now takes the login path immediately:
403is expected here (syntheticstate); the point is the request is issued, where before the fix no request was made and the UI sat on the binding screen for 30s.Checks
New tests cover: bind popup detected as bind; login in a tab with a foreign opener stays login (the regression); marker for another provider ignored; marker without opener; closed opener; storage unavailable.
🤖 AI assistance disclosure
I am not one of this repository's core contributors. This fix was AI-assisted: the root cause was found by instrumenting the real callback in a browser, and I reviewed the diagnosis, the change and the tests before submitting.
🤖 Generated with Claude Code
Summary by CodeRabbit
Bug Fixes
Tests