Skip to content

Console sign-in page cannot reach the mobile hand-off: a redirect to the auth API lands on 页面不存在 #29971

Closed

Description

@hotlong

Category: ① product defect with a named location and a reproduction.

Business reason: a user whose provider refuses embedded WebViews (Google among them) cannot sign in to the mobile app. objectstack-ai/mobile#148 opens the Console's sign-in page in the system auth session, and the Console has to finish by navigating to the hand-off #29766 shipped. Today it lands on 页面不存在, so the server half in 18.3.0 has no way to be reached.

Reader: domain:ui, one PR on ui/apps/console/src/pages/auth/LoginPage.tsx. objectstack-ai/mobile#148 waits on it.

Duplicate search: repo:objectstack-ai/objectstack trustedAppSchemes (5 hits, all closed, the spec and server halves) and "one-time-token" OR "handoff" login redirect (47 hits, none about this).

reach: measured on the released Console, @objectstack/cli@18.3.0 with the scheme registered, 2026-11-03.

Reproduction

  1. Boot 18.3.0 with trustedAppSchemes: ["ai.objectos.mobile"] and open, signed out:
    /_console/login?redirect=%2Fapi%2Fv1%2Fauth%2Fone-time-token%2Fhandoff%3Fredirect_uri%3Dai.objectos.mobile%253A%252F%252Fauth%252Fcallback%26code_challenge%3D<43 chars>%26code_challenge_method%3DS256
  2. Sign in.

Expected: the browser navigates to /api/v1/auth/one-time-token/handoff?..., which answers 302 ai.objectos.mobile://auth/callback?token=T.

Actual: the address becomes /_console/apps/com.objectstack.setup/api/v1/auth/one-time-token/handoff?... and the page says 页面不存在.

Cause

LoginPage.tsx:266 and :276 send every safe redirect through withConsoleBase, which treats it as a Console route: window.location.assign(withConsoleBase(isSafeRedirect(redirect) ? redirect : '/')). The hand-off is an API route, outside the Console mount. Only a path starting with /_ passes through unprefixed.

What the page has to do

The hand-off's own rules (packages/plugins/plugin-auth/src/one-time-token-handoff.ts, ruling A on #29714) set these:

  1. Reach the route, and nothing wider. A redirect whose path is exactly the hand-off under the deployment's base path navigates there unprefixed. Every other redirect keeps today's behaviour. This must not become a general way out of the Console mount. Match on AuthEndpointPaths.oneTimeTokenHandoff rather than a copied string.
  2. Navigate by script from this page. The route refuses Sec-Fetch-Site: cross-site, and a redirect chain through the provider reports cross-site. So the provider's callback has to land on this page first (the social buttons' default callbackURL already does), and the page then calls location.assign, which reports same-origin. Passing the hand-off as callbackURL would be refused.
  3. Before the organization steps. Today a user with several organizations goes to /organizations and the redirect is dropped; one with no active organization waits on switchOrganization. The hand-off carries the session and nothing else, and the app's WebView runs those steps after it. So this branch comes first.
  4. A session older than five minutes. The route answers 401 as raw JSON to a session created more than five minutes ago. iOS uses an ephemeral auth session, so it arrives signed out. Android's Custom Tabs share Chrome's cookies and have no ephemeral mode through expo-web-browser, so a user who signed in to this server in Chrome last week arrives signed in, the page forwards at once, and they are stranded on JSON. On a hand-off redirect the page should show the sign-in form for such a session instead of forwarding, which needs the session's createdAt. How to get there (sign that browser session out first, or re-authenticate on top of it) is the card's design question, and it belongs in the PR body.

Acceptance notes

  • A unit test per rule above, including a redirect such as /api/v1/data/x staying a Console route as it is today.
  • Browser check: the reproduction above ends in the 302, for a password sign-in and for a provider sign-in.
  • RegisterPage.tsx:189 and :197 have the same shape. A user who registers from the auth session has the same problem, if sign-up is reachable there; handle it or say why not.
  • This is a session-transfer flow: the maintainer reviews and merges.

Blocks: objectstack-ai/mobile#148

Activity

  1. objectstack-fleet commented on Nov 3, 2026

    @objectstack-fleet
    Contributor

    triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U

    Path: none

    Triage: first-touch audit · p2 · domain:ui · pm:queue · 2026-11-03T19:52Z

    ⛔ Not a claim.


    Generated by Claude Code

  2. objectstack-fleet commented on Nov 3, 2026

    @objectstack-fleet
    Contributor

    domain:ui#1 · os-support-ai · session_01WGufzjDyeFwq6AuFu3vwTd

    Claim: PM loop round 1 · 2026-11-03T20:33Z
    Session: session_01WGufzfukSC9MVr7odALmfg (card thread; dispatching seat session_01WGufzjDyeFwq6AuFu3vwTd)
    Account: os-support-ai
    Branch: claude/project-thread-ilbjip (platform-assigned to the card thread; it can push only this branch)
    Worktree: objectstack-issue-29971
    Domain: domain:ui
    Seat: domain:ui#1
    File surface: ui/apps/console/src/pages/auth/LoginPage.tsx, RegisterPage.tsx, a small redirect helper beside them if needed, their tests, one root .changeset/ file; ⛔ no packages/plugins/plugin-auth/**, no packages/spec/** (stop on breach; explain in the report)
    Container & model: S/M, mode:thread, model: TIER_DEFAULT (dispatch-gates --tier: no path-derived mandate), effort: xhigh
    Clause-②: no
    Metadata-protocol: none
    Responsibility: ui code produces it (domain:ui) | no platform path covers it | every Studio/Console user who reaches the surface named in the card body
    Thread-read: 6614012375
    Premise: holds (re-read on origin/main 18112bfa7: LoginPage.tsx L266/L276 still pass every safe redirect through withConsoleBase; AuthEndpointPaths.oneTimeTokenHandoff is /one-time-token/handoff)
    Serial constraints cleared: none in flight on ui/apps/console/src/pages/auth/** (#29901 metadata-admin/ResourceEditPage.tsx; #29935 PR #29970 core/actions). Landing: maintainer reviews and merges (session-transfer flow, card body)
    Order: /mnt/project-files/ui-orders/29971.md · maintainer scope 「车队可以继续派发了,只派发 p1 p2 ,总并发不超过 20」 (2026-11-03T15:40Z)

  3. objectstack-fleet commented on Nov 3, 2026

    @objectstack-fleet
    Contributor

    domain:ui#1 · os-support-ai · session_01WGufzfukSC9MVr7odALmfg
    os-dev-report
    Stamp: 2026-11-03T21:51Z

    {
      "issue": 29971,
      "status": "done",
      "branch": "claude/project-thread-ilbjip",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/29979",
      "session": "session_01WGufzfukSC9MVr7odALmfg",
      "premise_still_valid": true,
      "summary": "New ui/apps/console/src/pages/auth/handoffRedirect.ts: handoffNavigationTarget answers the unprefixed target only for a root-relative redirect on this origin whose parsed path equals getAuthEndpointUrl(VITE_SERVER_URL + /api/v1/auth, 'oneTimeTokenHandoff') exactly (no copied string); useHandoffRedirect judges each session once in a layout effect against a five-minute window (createdAt when present, else made-on-this-page vs found-on-arrival). LoginPage and RegisterPage gain a post-sign-in step after the OAuth authorize replay and before the organization steps: the hand-off is assigned once with window.location.assign; a session the route would refuse keeps the sign-in or sign-up form on screen, and a sign-in on it re-authenticates on top of the old session (design justified in the PR body: signOut() can leave the page for the provider's end-session URL and would sign the user out of the shared browser Console). Every other redirect, /api/v1/data/x included, keeps withConsoleBase. Measured: the auth client's getSession() passes the wire session through, so createdAt is readable with no new endpoint and no change to @object-ui/auth; no packages/spec or plugin-auth file touched. Head c6ac92e1 on origin/main 18112bfa7. The card's assignee was not written.",
      "tests": "New: handoffRedirect-29971.test.ts and signInHandoff-29971.test.tsx (real AuthProvider over a mock client, Console mounted with base href /_console/): one pin per rule, CONTROL /api/v1/data/x lands on /_console/api/v1/data/x, lookalikes (handoffX, trailing slash, extra segment, verify, upper case, encoded slash, under /_console), a different base path both ways, absolute https://evil.example, protocol-relative, backslash, tab, javascript:, an auth base on another origin, the 4 min / exactly 5 / 6 min / a week window, a 6-minute-old session showing the form and a sign-in on it forwarding, RegisterPage fresh / CONTROL / stale. pnpm --dir ui exec vitest run on the two files: Test Files 2 passed, Tests 29 passed. Ablation via scripts/ablation-replace.mjs on LoginPage.tsx, anchor 'if (handoff.target) {' to 'if (false && handoff.target) {' x1 to x0, blob 5339378f1236 to 98b3e8304e15, run with -t LoginPage: 7 failed / 5 passed / 3 skipped; red: rule 1 'navigates to the hand-off unprefixed, outside the Console mount' (assign called 2 times), both rule-3 pins (expected './api/v1/auth/one-time-token/handoff?...' to be '/api/v1/auth/one-time-token/handoff?r...'), four rule-4 pins; CONTROLs, rule 2 and the rule-4 CONTROL green; restored to HEAD blob 5339378f1236, git diff HEAD empty. Full Console project (pnpm --dir ui exec vitest run --project @object-ui/console): 295/295 files, 2870/2870 tests, exit 0. Browser, live on pnpm dev:crm -- --fresh with the Console built from this head (Playwright Chromium, dev-seeded admin, password sign-in): /_console/login?redirect=(hand-off) ends in GET /api/v1/auth/one-time-token/handoff with Sec-Fetch-Site same-origin, Sec-Fetch-Mode navigate, answered 302 to ai.objectos.mobile://auth/callback?token=(present, redacted), no not-found page; CONTROL ?redirect=/api/v1/data/x navigates to /_console/api/v1/data/x. Stale session, live: a browser signed in at 21:39:21Z (get-session createdAt read back) opened on the hand-off redirect at 21:45:00Z shows the sign-in form and makes no hand-off request (that session's cookie alone gets 401 from the route); a sign-in on the form then ends in the same 302 with a token, Sec-Fetch-Site same-origin; the old session still answers get-session afterwards (re-authenticated on top, not revoked). Provider (social) sign-in NOT MEASURED live: no identity provider in the container (socialProviders empty); covered by the rule-2 unit pin. The live boot needed a local measurement-only trustedAppSchemes line in packages/cli serve.ts (see out_of_scope_findings), never committed, source and cli dist restored.",
      "gates": "Local, head c6ac92e1, under scripts/pm/os-verify-lock.sh: pnpm --filter '@object-ui/console^...' build exit 0; pnpm --filter @object-ui/console run build && run type-check exit 0; ui/scripts/check-eager-closure-budget.mjs pass (2109.4 KB gzipped, budget 2431.6 KB, vendor-objectstack 577.4/823.0 KB); eslint on the touched files 0 errors, 5 warnings (4 existing react-hooks/set-state-in-effect in the two pages, 1 new of the same warn-level rule in handoffRedirect.ts); node scripts/check-sdui-manifest.mjs exit 0 and --self-test exit 0; dispatch-gates --commands derived 34 commands, all 34 exit 0; dispatch-gates --ran: 34 derived famil(ies) accounted for — 34 run, 0 NOT-MEASURED. CI check-runs on PR 29979: not concluded at report time, not awaited; the seat reads them.",
      "dogfood_battery": "n/a — no ! in the PR title and no (narrowing) arm",
      "mcp_calls": "5 — GitHub MCP reads only: issue_read get #29971, issue_read get_comments #29971 x2, issue_read get #29714, issue_read get_comments #29714; no write tool",
      "api_writes": "2 — relay pr_create (objectstack-ai/objectstack#29979, draft, no labels, no assignee, no reviewer, body read back) and this os-dev-report comment via post-stamped; plus 2 git push (not REST writes)",
      "open_questions": [],
      "security_findings": "none",
      "out_of_scope_findings": [
        "class: a · reach: public door + wrong answer, measured: a stock os serve (packages/cli serve.ts composes AuthPlugin with no trustedAppSchemes, and no settings key or env var sets it; 0 hits for trustedAppSchemes under packages/cli and packages/runtime) answers a signed-in same-origin GET /api/v1/auth/one-time-token/handoff?redirect_uri=ai.objectos.mobile://auth/callback with 400 VALIDATION_ERROR (measured on a stock pnpm dev:crm boot; its message tells the deployment to register the scheme in its auth configuration, which that boot has no way to do), so the shipped server half of the mobile hand-off cannot be used on an os serve deployment; the same request on a boot with the one-line local patch answers 302 with a token · dedupe words: trustedAppSchemes os serve, one-time-token handoff app scheme, mobile sign-in redirect_uri refused",
        "carrier: objectstack-ai/mobile#148 · noted, not filed: RegisterPage verificationCallbackURL and the verify-email resend still resolve ?redirect= as a Console route, so a sign-up that needs email verification does not come back to the hand-off; the mail link opens outside the system auth session, so whether the shell wants another way back is the mobile card's call. Recorded in the PR Acceptance notes",
        "carrier: none · noted, not filed: HANDOFF_SESSION_MAX_AGE_MS in the Console restates plugin-auth ONE_TIME_TOKEN_HANDOFF_SESSION_MAX_AGE_SECONDS (server-only module, out of this card's scope); drift only changes form vs the route's own 401. Dedupe words: handoff session max age constant Console copy"
      ]
    }

    Deviations: (1) The branch is the harness-pinned claude/project-thread-ilbjip, which the seat's Claim names, not the claude/issue-29971-slug form. (2) The PR body's attribution line links this card thread, as the session's own required attribution block spells it, not the seat thread the order quoted. (3) A local, measurement-only trustedAppSchemes line in packages/cli/src/commands/serve.ts was built into the cli dist for the live check only; it was never committed, the source is back to its HEAD blob and the cli dist was rebuilt from clean source. (4) One new warn-level eslint finding (react-hooks/set-state-in-effect) in handoffRedirect.ts, the same rule as four existing warnings in the two pages. (5) The platform PR subscription was not taken, per the os-dev contract. The harness's default PR footer was replaced by the AGENTS session-URL footer.

  4. objectstack-fleet commented on Nov 3, 2026

    @objectstack-fleet
    Contributor

    domain:ui#1 · os-support-ai · session_01WGufzjDyeFwq6AuFu3vwTd

    ACCEPT (seat read) → PR #29979 comment 6616373413 · head c6ac92e1 · 2026-11-03T21:55Z. Session-transfer flow: a maintainer reviews and merges; the seat marks it ready when CI is green and does not queue it.

  5. hotlong commented on Nov 3, 2026

    @hotlong
    ContributorAuthor

    Evidence that this is the only piece missing on a self-hosted server, from objectstack-ai/mobile#190 on an iOS simulator against @objectstack/cli@18.3.0 with the scheme patched in.

    A local proxy answered the mis-prefixed /_console/apps/<app>/api/v1/auth/one-time-token/handoff?... with a 302 to the real route. With that alone, the whole flow worked: sign-in in the auth session, the hand-off's 302 to the app, and redemption in the WebView, which opened the Console signed in.

    So the app and the server both accept what the Console has to send: a same-origin navigation from /login. The password sign-in was the only one tried, so rule 2 (a provider's redirect chain landing on /login first) is still argued, not measured.

  6. objectstack-fleet commented on Nov 4, 2026

    @objectstack-fleet
    Contributor

    domain:ui#1 · os-support-ai · session_01WGufzjDyeFwq6AuFu3vwTd

    Landed · PR #29979 · f35b361997 · 2026-11-04T13:11Z

    Probe Reading
    PR #29979 merged by the merge queue at 2026-11-04T13:11Z, merge commit f35b361997 (on origin/main)
    Head at landing ccd2e42f: the seat's main merge over the ACCEPTed page code, CI green (45 success, 11 expected skips), no approval required (mergeable_state clean)

    ACCEPT 6616373413. The card is closed by Fixes #29971 (state: closed completed). pm:dispatched is removed in this stroke. #30010 (for #29997) can now carry the page branch in handoffRedirect.ts as hotlong planned.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions