Skip to content

Commit cde492d

Browse files
objectstack-fleet[bot]hotlongclaude
authored
feat(plugin-auth): a page navigation to the mobile hand-off that has to sign in first goes to the sign-in page (#30010)
domain:server#2 · hotlong · session_c967d7a472b0485c94711bfcb22159c8 Fixes #29997 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:手机 App 登录现在只需打开交接接口这一个地址。没登录、或会话超过 5 分钟的用户在系统浏览器里打开它,服务端直接跳到本部署的 Console 登录页,登录后回到交接接口,再把一次性令牌交回 App。App 自己的探测请求仍然拿到 401。登录页也一起改了:服务端跳转时写明拒绝时间,登录页按这个时间判断会话,不再只看设备时钟。 - **为什么改**:Google 这类不允许内嵌 WebView 的登录方式走这条流程,装好的 App 会用好几个月。原来 App 要自己知道 Console 登录页的路径和它怎么处理 `redirect`,Console 一升级改了其中一个,这些用户就卡在没有退路的页面上,App 也察觉不到。 - **风险与回滚**:目前还没有已发布的 App 在用这条流程,所以不存在受影响的旧版;现有入口(直接打开 Console 登录页)对用它的构建照常可用。这是会话转移流程,但没有新增能力:令牌仍只发给 5 分钟内登录的会话,跨站打开和非法参数仍先回 400,不会跳到登录页;没挂 Console 的部署保持 401。回滚即还原本 PR。 - **先看**:`packages/plugins/plugin-auth/src/one-time-token-handoff.ts` 里的 `refusedSessionAnswer`(什么时候跳、什么时候还回 401)和 `resolveHandoffSignInEntry`(只取路径不换源、怎么判断有没有 Console);`ui/apps/console/src/pages/auth/handoffRedirect.ts` 里的 `isHandoffSessionFresh`(登录页何时放行)。 - **两分钟验证**:`pnpm --filter '@objectstack/plugin-auth^...' build && pnpm --filter @objectstack/plugin-auth exec vitest run src/one-time-token-handoff.test.ts`,预期 88 条全部通过;`pnpm --dir ui exec vitest run apps/console/src/pages/auth/__tests__/handoffRedirect-29971.test.ts apps/console/src/pages/auth/__tests__/signInHandoff-29971.test.tsx`,预期 68 条全部通过。 - **需要你做的**:这是会话转移(auth)流程,请维护者审阅并合并本 PR。 ## What changes `GET /api/v1/auth/one-time-token/handoff`, for a valid request whose session is missing or older than five minutes: | Request | Answer | | --- | --- | | A page navigation | `302` to this deployment's Console sign-in page, with `redirect` naming the same request and `handoff_refused_at` carrying the server's time of the refusal | | Anything else (the app's probe included) | `401 UNAUTHENTICATED`, as before | | A page navigation reporting `Sec-Fetch-Site: same-origin`, which is the sign-in page forwarding | `401`, never another redirect | | A page navigation when this process does not answer the sign-in page with `200 text/html`, or when the path would start with `//` | `401`, logged once at `warn` | Nothing else on the route moves. `Sec-Fetch-Site: cross-site` is still judged first, then the closed query set, the registered scheme and the challenge; an invalid request gets `400` and never reaches the sign-in page. A token is still minted only for a session under five minutes old (ruling A on #29714). The Console's sign-in page (`handoffRedirect.ts`): when the query has `handoff_refused_at`, a session with a `createdAt` is fresh only if it was created after that time. The device's clock is not read for it. A value that is not a whole number of milliseconds is ignored, and a session with no `createdAt` keeps its existing branch. The links between `/login` and `/register` carry the mark beside a hand-off `redirect`, and every other link is byte-identical. Spec: docblock only. Route ledger: the note. No schema, export or generated artefact moved. ## Design **Telling a page navigation from an API call (constraint 1).** `Sec-Fetch-Dest` decides when a browser sends it: `document` is a navigation, and `empty` (a `fetch`) or `iframe` is not, whatever `Accept` says. With no Fetch Metadata, `Accept` naming `text/html` counts. A request with neither gets the `401`, which is what this route answered before. So the rule rests on no single header, and if a real system auth session turned out to send neither, the result is today's JSON rather than a wrong redirect. NOT MEASURED: the headers a real `ASWebAuthenticationSession` or Custom Tabs sends. I cannot measure those here. Measured in headless Chromium 151 opening the route directly: `Sec-Fetch-Dest: document`, `Sec-Fetch-Site: none`, `Sec-Fetch-Mode: navigate`, an HTML `Accept`. The WebKit and Firefox builds Playwright has on this machine do not match its version, so they were not read either. **Staying on the origin (constraint 3).** `AuthManager.getConsoleUrl` composes `${canonical origin}${uiBasePath}${path}` (confirmed in source), so using it as `Location` would move a browser that reached the deployment on another host. `Location` is root-relative and takes only the path of that URL, which keeps `uiBasePath` and any base path in `baseUrl` honoured without a second composer. Measured through a proxy on another port: the browser stayed on the proxy's origin through the sign-in and ended at the app. A `baseUrl` ending in `//` or a `uiBasePath` starting with `//` would compose a path starting with `//`, which as a root-relative `Location` names another host. Config alone reaches that and no token rides that redirect, but the route treats it as no sign-in page: the `401`, one `warn`, and the server is not asked. **`redirect` (constraint 4)** is rebuilt from the validated parameters only: `redirect_uri`, `code_challenge`, and `code_challenge_method` if the request had it. The app's `redirect_uri` stays nested inside `redirect`, because `redirect_uri` at the top of the page's query, with `client_id`, is what the page reads as an OAuth authorize replay. **No Console (constraint 6).** The route asks the composed server, in-process and per request, for the sign-in page, and redirects only when that answers `200` with an HTML document. `readShellConfig` and `anonymousFormDoorServes` in `packages/cli/src/utils/console.ts` already ask the composed app the same way. A route table would not do: `os serve` mounts a `503` responder on the same paths when the Console was not built. So these all keep the `401`: `--no-ui`, a Console not built, a host that redirects `/_console` to the control plane. So does a Console served by something in front of this process, which is the cost of asking in-process: that deployment stays on today's JSON. **The execution context.** `resolveHandoffSignInEntry` calls `server.fetch` with `env` and no execution context, and I left it that way because it cannot matter on any host read: - `c.executionCtx` is read nowhere in this repo, nor in `objectstack-ai/cloud` at `f874972e`. - In Hono itself only `middleware/cache` and `middleware/method-override` read it unguarded, and neither is imported here. `.mount()` reads it inside a `try`. - No real request has one either. `plugin-hono-server` and cloud's `node-server.ts` serve through `@hono/node-server`, which calls `fetch(req, { incoming, outgoing })`, so the probe matches the request it came from. Cloud's Workers front a Node container and use `ctx.waitUntil` outside Hono. Cloud's Vercel entry calls `app.fetch(request)` alone. `readShellConfig` already dispatches the same way, on every Console document. If a middleware ever did read it unguarded, the probe answers `500`, the route keeps the `401` and logs `it answers 500` once. A new pin holds that: it fails closed and says why. **The loop (constraint 5) is real, and no longer happens.** Measured at `584f1e43fc`, in headless Chromium; the commit after it changes none of what these runs read. Every run below used a session really older than five minutes (each waited 5m10s). The device clock was set 15 minutes behind where the row says so. | Run | Hand-off requests | Where it ended | | --- | --- | --- | | With Fetch Metadata, device clock behind, old session | 1: `302` (`none`) | The form, held for the 10 s watched; a sign-in on it ends in the `302` to the app | | No Fetch Metadata, device clock behind, old session | 1: `302` | The form, held for the 10 s watched; a sign-in on it ends in the `302` to the app | | With Fetch Metadata, session created after the mark, same URL reopened once stale | 1: `401` (`same-origin`) | JSON, and it stops | | No Fetch Metadata, session created after the mark, same URL reopened once stale | 1: `302` | The form, under the newer mark, held for the 10 s watched | For scale, the loop itself, measured earlier at `fe2b8b4907` with #29979 merged in, the same-origin stop removed and no page half: **228 hand-off requests in 20 s, all `302`**. The session-created-after-the-mark rows are the remaining way the page forwards a session the route refuses: a URL reopened more than five minutes later. The same path covers a session the page can read and the route cannot, such as a bearer token with no cookie, which was read from the code and not run. "No Fetch Metadata" is Chromium behind a proxy that strips `sec-fetch-*`, so the server really sees none; no browser that truly lacks those headers was run. **Why the mark is a time and not a bare mark.** `SocialSignInButtons` defaults `callbackURL` to `window.location.href`, so a provider sign-in comes back to the same URL with the mark still on it. A bare mark would then hold the form over the brand-new session, which is a dead end for exactly the Google users this flow exists for. With a time, the new session's `createdAt` is later and the page forwards. It also spares a device whose clock is ahead, where a fresh provider session read as too old. No identity provider exists on this machine, so that return was measured as a sign-in made elsewhere followed by opening the same URL again: it ended at the app with the device clock 15 minutes behind and 15 minutes ahead. On one process the mark and `createdAt` are two readings of one clock. On several nodes behind one host they are two clocks, so skew can hold the form until a sign-in makes a later `createdAt`, which clears it. The docblocks say so. **The mark across the sign-in pages.** `registerUrl` on `/login`, and on `/register` both `loginUrl` and the bounce a closed sign-up makes, now carry the mark beside a hand-off `redirect` (`handoffMarkQuery`). Beside any other `redirect`, or none, those links are byte-identical, and a test pins each. Reading of `RegisterPage` with the mark, from the code and its pins. `useHandoffRedirect` reads the mark from the URL when it judges a session, so `/register` behaves as `/login` does. A session not created after the mark holds the form, whatever the device's clock says. A sign-up on that form changes the session to one with no `createdAt`, which is not the one found on arrival, so it forwards. A session created after the mark, which is a provider returning to this URL, forwards. Measured at this PR's head, under the real router, with a route-issued mark, an old session and the device clock 15 minutes behind: `/login` held, "Sign up" to `/register` with the mark kept and the form held, "Sign in" back to `/login` with the mark kept and the form held, one hand-off request throughout; a sign-in then made the second hand-off request. The showcase is invite-only, so showing "Sign up" took patching the config response with Playwright interception, and under interception a `302` to the app's scheme reports `net::ERR_ABORTED`. A control with interception and no round trip reads the same. The `/register` to `/login` direction, run with no interception, read that second request as the `302`. ## Landing order and release #29979 and #30004 are both on `main`, and the route and the page now change together in this PR, so nothing has to land first. This carries in the next release after it merges: 18.4.0 as pending changesets stand (13 of 62 are `minor`, and these packages version together). objectstack-ai/mobile#190 can change its entry only once its server runs that release, because an older server answers the new entry with JSON. ## Tests - `one-time-token-handoff.test.ts`: 88 tests on the real `AuthManager`, the real better-auth plugin and the real route registration. Page navigation with no session and with an old session gets the `302`; JSON, no headers, `*/*`, a `fetch` asking for HTML and an iframe get `401`; an unregistered scheme, a bad challenge, a plain method and an unknown parameter get `400` with no redirect; cross-site gets `400` first; the `redirect` it names signs in, comes back and redeems. A path starting with `//` gets the `401` and one `warn` and asks the server nothing. `resolveHandoffSignInEntry` and `isPageNavigation` have their own tables. - `handoffRedirect-29971.test.ts` and `signInHandoff-29971.test.tsx` (the existing harness files for this component, so the new pins are top-level `describe`s there): a session created after `handoff_refused_at` is fresh and one not created after it is held, each with the device clock agreeing, an hour behind and an hour ahead; a value that is not digits is ignored and the five-minute check decides; a session with no `createdAt` keeps its branch; on the real `LoginPage` and `RegisterPage`, the form holds, a sign-in or sign-up on it forwards, and a session created after the mark forwards. Each of the three links carries the mark beside a hand-off `redirect` and stays byte-identical beside any other `redirect`, with no mark on the page, and with no `redirect`. - Pins: the two existing `401` pins on the route send no navigation headers, so they stay and their titles now say so. No other pin of this route's `401` exists in the repo. - `examples/app-showcase/e2e/mobile-signin-handoff.spec.ts`, two tests. Signed out, open the hand-off, land on the form on this origin, sign in, see the `302` to the registered scheme, and redeem the token with the verifier (and fail without it). And a sign-in made elsewhere that returns to the same URL forwards on a device whose clock is ahead, which is what notices the route and the page disagreeing on the parameter's name. ## Acceptance notes - **Where the browser test lives, and that it is nightly.** Not `ui/e2e`: `Live E2E UI` serves the Console from `vite preview` at the root and the backend there has no built Console to serve, so the route's redirect has nothing to land on. `examples/app-showcase/e2e` is the one lane where the backend serves the Console at its real mount. It runs in Nightly Tiers and a red night files a card, so a Console change that breaks this shows up the next night, not on that PR. The two tests add about two seconds to that suite. - **Scheme registration.** A test-only stack config works only at the app root (the compile resolves connector spec paths from the config's directory) and through `composeStacks` (a spread loses the `defineStack` provenance, and `defineStack` on a built stack reports 71 cross-reference issues), and every smoke spec would then boot a composed stack. `OS_TRUSTED_APP_SCHEMES` on the `webServer` is one line and the path a deployment uses. - **File surface beyond the first claim:** `examples/app-showcase/playwright.config.ts` (that one line), the doc comment of `examples/app-showcase/test/showcase-smoke.live.test.ts`, the page half, and `LoginPage.tsx` and `RegisterPage.tsx` only for the mark on the links between them, per the claim updates on the card. - **One path still drops the mark.** `/verify-email-prompt` is given only `redirect` by both pages, and `VerifyEmailPromptPage`'s "back to login" builds `/login?redirect=…` from that. So a person sent there holding an old session, on a device whose clock is behind, who goes back is judged by the age check. A forward then ends at the route's `401` with Fetch Metadata, or at one more redirect whose mark holds the form without it. Read from the code, not run. It stops rather than loops. Left alone: `VerifyEmailPromptPage.tsx` is outside this card's surface. - **Cloud does not offer this flow yet:** objectstack-ai/cloud#3549. - `packages/qa/dogfood/test/principal-session-console-boot.dogfood.test.ts` skips on every CI run as far as the workflows read: none sets its Chromium path, and none builds the Console on that job. Noted, not filed. ## Verification Head `3e59db0af0`, one commit on `584f1e43fc`, which the contract review read. `main` was not merged again: no conflict and nothing red forced it, so the merge with `origin/main` stays `f68b47cace`. | Check | Result | | --- | --- | | `pnpm --filter @objectstack/plugin-auth exec vitest run` | 220 files; 3864 passed, 10 skipped | | `pnpm --dir ui exec vitest run apps/console/src/pages/auth/__tests__/` | 27 files, 217 passed | | `pnpm --filter @objectstack/plugin-auth run typecheck`, `tsc --noEmit` on `@objectstack/spec` | exit 0 | | `pnpm --filter @object-ui/console run type-check` | exit 0 | | eslint, `--no-inline-config` on the server-side files, `pnpm --dir ui exec eslint` on the page files | exit 0; 0 errors, and 5 warnings, the `react-hooks/set-state-in-effect` these pages and this hook already carried | | `dispatch-gates --commands` (12 paths, 88 commands) | 87 exit 0, 1 exit 1: `pnpm --dir ui exec vitest run scripts/`, read below | | `dispatch-gates --ran` | `88 derived famil(ies) accounted for — 88 run, 0 NOT-MEASURED` | | The five roster gates whose list lives under a touched directory (`check:meta-url-spelling`, `check:authz-resolver`, `check:changeset-fixed`, `check:error-code-casing`, `check:filter-alias-parity`) | exit 0 each | | `pnpm --filter @objectstack/spec check:generated` | exit 0: all 14 generated artifacts up to date, so the docblock moved none | | Browser spec, Console from `pnpm console:build` at this head | 2 passed | **The one non-zero gate.** `pnpm --dir ui exec vitest run scripts/` read 5 failed of 4987 tests on this Mac, none in a file this PR touches. Three were whole-tree scans hitting the 15 s test timeout, and those files pass alone with a longer timeout; which ones hit it varies by run. The other two are in `select-affected-tests-wiring.test.ts` and stop at `mapfile: command not found`: macOS ships bash 3.2. CI's run on Linux is the authority for this family: **`Test UI` concluded `SUCCESS` on this head, all 16 shards**, and the shard logs show those files, and the ones the previous head's local run timed out on, passing there. <details> <summary>Ablations on the browser spec (Chromium 151, headless; each restored and rebuilt)</summary> | Mutation | Red | | --- | --- | | Route's redirect removed, plugin-auth rebuilt, `ablation-dist-preflight` confirmed absent from `dist/` | Both: 25 s timeout at `#login-email`, on the JSON page | | Page stops recognising the hand-off (`handoffNavigationTarget` returns null), Console rebuilt | Both: 45 s timeout waiting for the `302` to the app, which is what a Console upgrade could do | | Route's parameter renamed, plugin-auth rebuilt, `ablation-dist-preflight` confirmed in `dist/` | The second: 45 s timeout, the page held its form. The first stayed green. | Taken at `584f1e43fc`. After restoring and rebuilding, the spec read 2 passed. </details> <details> <summary>Unit ablations, through scripts/ablation-replace.mjs (each restored; blob equals HEAD)</summary> | Mutation | Red | | --- | --- | | Page: `refusedAt` branch deleted | 8 | | Page: reader not wired into the verdict | 3, all on the real `LoginPage` | | Page: digit check removed | 5 | | Page: `handoffMarkQuery` not limited to a hand-off `redirect` | 2 | | `/login`: "Sign up" without the mark | 1 | | `/register`: "Sign in" without the mark | 1 | | `/register`: closed sign-up bounce without the mark | 1 | | Route: `isPageNavigation` guard deleted | 5 | | Route: same-origin stop deleted | 1 | | Route: `//` guard off | 4 | | Route: `//` guard on the sign-in page only, not the hand-off | 1 | | Route: served check made `true` | 6 | | Route: the `302` replaced by the refusal | 8 | | Route: `Location` given the full URL instead of the path | 6 | | Route: `handoff_refused_at` deleted | 2 | The `//` guard and link rows are at `3e59db0af0`, the others at `584f1e43fc`. </details> **Declared narrowing — verification ran UNLOCKED.** `scripts/pm/os-verify-lock.sh` could not take the shared verify lock on this host: no usable `flock`. The shared verify lock is declared Linux-only (`flock` is util-linux, and a stock macOS does not ship it), so the commands above were run directly, without the lock — a declared narrowing, not a silent one. No serialization guarantee held for these runs, nor for any sibling agent in this container while they ran. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (Docblock only.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; eslint on the touched files passes. (CI's `pnpm lint` and `Lint UI` are the authority.) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: no generated artifact moved. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. (The maintainer brief above is Chinese by the project's convention.) - [x] The *Before Submitting* list in CONTRIBUTING.md is done. Body written 2026-11-04T17:22Z. --- _Generated by [Claude Code](https://claude.ai/code/session_c967d7a472b0485c94711bfcb22159c8)_ --------- Co-authored-by: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent b7075d9 commit cde492d

14 files changed

Lines changed: 1145 additions & 75 deletions
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
'@objectstack/spec': patch
4+
'@object-ui/console': patch
5+
---
6+
7+
feat(plugin-auth): a page navigation to the mobile sign-in hand-off that has to sign in first goes to the sign-in page, so the app opens only the hand-off
8+
9+
Clause-②: no
10+
Metadata-protocol: none
11+
12+
`GET /api/v1/auth/one-time-token/handoff` answered a missing or old session with `401` as raw JSON, so the mobile app had to open the Console's `/login` route itself and know how that page treats `redirect`. An installed app stays in use for months, so a Console upgrade could strand it. Now the app opens only the hand-off:
13+
14+
- A page navigation with no session, or with one older than five minutes, answers `302` to this deployment's Console sign-in page, with `redirect` naming the same request. `Location` is root-relative, so the browser stays on the origin the request arrived on, and `uiBasePath` is honoured.
15+
- Any other request still answers `401`, so the app's anonymous probe keeps reading "this server registered me". A navigation reporting `Sec-Fetch-Site: same-origin` answers `401` too: it is the sign-in page forwarding a session the route refuses. A deployment that does not serve the Console keeps the `401` too, and says so once at `warn`.
16+
- A token is still minted only for a session under five minutes old, and every `400` (a cross-site navigation, an unknown parameter, an unregistered scheme, a bad challenge) is still decided first, with no redirect.
17+
- A device whose clock is behind does not bounce between the route and the sign-in page. The redirect carries `handoff_refused_at`, the server's time of the refusal, and the page holds its form for any session not created after it, whatever the device's clock says. A session created after it forwards, so a provider's sign-in that returns to the same URL still ends at the app. The links between the sign-in and sign-up pages carry the mark beside a hand-off `redirect`, and every other link is unchanged.
18+
19+
An app can change its entry only once its server runs this release: an older server answers the new entry with JSON.
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { test, expect } from '@playwright/test';
2+
3+
// Ambient `process` for the env reads below: the showcase tsconfig does not pull
4+
// in `@types/node` (same idiom and reason as the specs beside this file).
5+
declare const process: { env: Record<string, string | undefined> };
6+
7+
/**
8+
* The mobile sign-in, end to end, in a real browser against the real Console: the
9+
* app opens the hand-off route and nothing else, the signed-out browser lands on
10+
* the sign-in page, and after the sign-in the route hands a token to the app.
11+
*
12+
* An installed app stays in use for months while the server under it upgrades, so
13+
* this is what notices a Console upgrade breaking it for those people: a sign-in
14+
* route that moved, or a page that stopped treating `redirect` as the hand-off.
15+
* No unit test holds both ends. It runs in the nightly `live` tier
16+
* (`test/showcase-smoke.live.test.ts`), the one lane where the backend serves the
17+
* Console at its real mount, as a deployment does: `Live E2E UI` serves it from
18+
* `vite preview` at the root, where the route's redirect has nothing to land on.
19+
*
20+
* On a server that did not boot with `playwright.config.ts`'s
21+
* `OS_TRUSTED_APP_SCHEMES`, such as one `reuseExistingServer` attached to, the first
22+
* assertion fails with a `400`: no scheme is registered there.
23+
*/
24+
25+
const API = process.env.SMOKE_API_URL || 'http://localhost:3000';
26+
const EMAIL = process.env.SMOKE_EMAIL || 'admin@objectos.ai';
27+
const PASSWORD = process.env.SMOKE_PASSWORD || 'admin123';
28+
29+
/** The scheme `playwright.config.ts` registers on its `webServer`. */
30+
const HANDOFF_APP_SCHEME = 'com.example.showcase.e2e';
31+
const CALLBACK = `${HANDOFF_APP_SCHEME}://auth/callback`;
32+
/** An RFC 7636 verifier, as the app makes one, and `BASE64URL(SHA-256(verifier))`. */
33+
const VERIFIER = 'showcase-e2e_mobile.handoff~verifier-0123456789abcdef';
34+
const CHALLENGE = '9IIwBgnDEsLVGM-qq6wnLXO6eiMqtyyEEOJEyOGnc5M';
35+
36+
const HANDOFF_PATH = '/api/v1/auth/one-time-token/handoff';
37+
const HANDOFF = `${HANDOFF_PATH}?${new URLSearchParams({
38+
redirect_uri: CALLBACK,
39+
code_challenge: CHALLENGE,
40+
code_challenge_method: 'S256',
41+
})}`;
42+
43+
// The hand-off exists for a browser with no session, so nothing from the suite's
44+
// sign-in comes along.
45+
test.use({ storageState: { cookies: [], origins: [] } });
46+
47+
test('a signed-out browser opens the hand-off, signs in on the sign-in page, and the route hands a token to the app', async ({ page, request }) => {
48+
// The app's probe: not a page navigation, so it reads `401` as "this server
49+
// registered me" and is never sent to a page.
50+
const probe = await request.get(HANDOFF, { headers: { Accept: 'application/json' }, maxRedirects: 0 });
51+
expect(probe.status(), await probe.text()).toBe(401);
52+
53+
const toApp = page.waitForResponse(
54+
(response) =>
55+
new URL(response.url()).pathname === HANDOFF_PATH &&
56+
response.status() === 302 &&
57+
(response.headers().location ?? '').startsWith(`${HANDOFF_APP_SCHEME}:`),
58+
);
59+
60+
// The one URL the app opens. The browser is sent to the form on this origin.
61+
await page.goto(HANDOFF, { waitUntil: 'domcontentloaded' });
62+
await page.locator('#login-email').waitFor({ state: 'visible', timeout: 25_000 });
63+
const signInPage = new URL(page.url());
64+
expect(signInPage.origin).toBe(new URL(API).origin);
65+
expect(signInPage.pathname).toBe('/_console/login');
66+
expect(signInPage.searchParams.get('redirect')).toBe(HANDOFF);
67+
68+
await page.locator('#login-email').fill(EMAIL);
69+
await page.locator('#login-password').fill(PASSWORD);
70+
await page.locator('form button[type="submit"]').first().click();
71+
72+
// The page forwards by script, and the route answers with the app's scheme. A
73+
// browser has no handler for that scheme, so the response is the end of the road.
74+
const answer = await toApp;
75+
expect((await answer.request().allHeaders())['sec-fetch-site']).toBe('same-origin');
76+
const location = new URL(answer.headers().location);
77+
const token = location.searchParams.get('token');
78+
expect(token).toMatch(/^[A-Za-z0-9_-]{32}$/);
79+
80+
// What the app does next: the token redeems for the holder of the verifier only.
81+
const intercepted = await request.post('/api/v1/auth/one-time-token/verify', { data: { token } });
82+
expect(intercepted.status()).toBe(400);
83+
const redeemed = await request.post('/api/v1/auth/one-time-token/verify', { data: { token: `${token}.${VERIFIER}` } });
84+
expect(redeemed.status(), await redeemed.text()).toBe(200);
85+
});
86+
87+
test('a sign-in made elsewhere that returns to the sign-in page forwards, on a device whose clock is ahead', async ({ page }) => {
88+
// What a provider's sign-in does: the page hands the provider its own URL, so the
89+
// browser comes back to that URL with a new session. This device reads that
90+
// session as too old, so the page forwards only because it believes the route's
91+
// `handoff_refused_at` over its own clock. A route and a page that stop agreeing
92+
// on that name end here.
93+
await page.clock.setFixedTime(new Date(Date.now() + 15 * 60_000));
94+
95+
await page.goto(HANDOFF, { waitUntil: 'domcontentloaded' });
96+
await page.locator('#login-email').waitFor({ state: 'visible', timeout: 25_000 });
97+
const signInPage = page.url();
98+
99+
const signIn = await page.context().request.post('/api/v1/auth/sign-in/email', {
100+
data: { email: EMAIL, password: PASSWORD },
101+
headers: { Origin: new URL(API).origin },
102+
});
103+
expect(signIn.status(), await signIn.text()).toBe(200);
104+
105+
const toApp = page.waitForResponse(
106+
(response) =>
107+
new URL(response.url()).pathname === HANDOFF_PATH &&
108+
response.status() === 302 &&
109+
(response.headers().location ?? '').startsWith(`${HANDOFF_APP_SCHEME}:`),
110+
);
111+
await page.goto(signInPage, { waitUntil: 'commit' });
112+
await toApp;
113+
});

‎examples/app-showcase/playwright.config.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@ declare const process: { env: Record<string, string | undefined> };
3131
* when the browser cache holds a different build than this `@playwright/test`
3232
* resolves, in which case every test dies at launch (RUNNER.md, "Environment
3333
* facts"). Unset, Playwright resolves its own browser exactly as before.
34+
*
35+
* ## What it registers
36+
*
37+
* `OS_TRUSTED_APP_SCHEMES` on the `webServer`: the one app scheme
38+
* `e2e/mobile-signin-handoff.spec.ts` hands a token to, set the way a deployment
39+
* sets it. No other spec reaches the hand-off.
3440
*/
3541
const BASE_URL = new URL(process.env.SMOKE_API_URL || 'http://localhost:3000');
3642
const PORT = BASE_URL.port || '3000';
@@ -58,6 +64,7 @@ export default defineConfig({
5864
}],
5965
webServer: {
6066
command: `node node_modules/@objectstack/cli/bin/run.js serve --dev -p ${PORT}`,
67+
env: { OS_TRUSTED_APP_SCHEMES: 'com.example.showcase.e2e' },
6168
url: `${BASE_URL.origin}/api/v1/runtime/config`,
6269
timeout: 180_000,
6370
reuseExistingServer: !process.env.CI,

‎examples/app-showcase/test/showcase-smoke.live.test.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,9 @@ import { tmpdir } from 'node:os';
1111
* `e2e/` holds the Playwright suite that drives the Console (served by the
1212
* backend at /_console) against the real showcase stack: every nav surface
1313
* renders cleanly (`e2e/showcase-smoke.spec.ts`), the ADR-0085 detail shapes
14-
* (`e2e/detail-shapes.spec.ts`) and the selection-bar capability gate
15-
* (`e2e/bulk-capability-gate.spec.ts`). It used to run in its own scheduled
14+
* (`e2e/detail-shapes.spec.ts`), the selection-bar capability gate
15+
* (`e2e/bulk-capability-gate.spec.ts`) and the mobile sign-in through the real
16+
* sign-in page (`e2e/mobile-signin-handoff.spec.ts`). It used to run in its own scheduled
1617
* workflow, which filed nothing when it went red, so a red was read by nobody.
1718
* This file is how the same suite runs under Nightly Tiers instead
1819
* (`.github/workflows/test-nightly-tiers.yml`): the `*.live.test.*` name puts

‎packages/plugins/plugin-auth/src/auth-plugin.ts‎

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ import { runSetInitialPassword } from './set-initial-password.js';
8282
import { runRegisterSsoProviderFromForm, runRegisterSamlProviderFromForm, runRequestDomainVerification, runVerifyDomain, type AuthRequestHandler } from './register-sso-provider.js';
8383
import { runResendVerificationEmail } from './send-verification-email.js';
8484
import { runSysSessionRevoke, sysSessionRevokeDepsFor } from './sys-session-revoke.js';
85-
import { runOneTimeTokenHandoff } from './one-time-token-handoff.js';
85+
import { resolveHandoffSignInEntry, runOneTimeTokenHandoff } from './one-time-token-handoff.js';
8686
import { readJsonRequestBody } from './request-json-body.js';
8787
import {
8888
seedCreatedOrganizationCurrency,
@@ -4030,10 +4030,13 @@ export class AuthPlugin implements Plugin {
40304030
// the app redeems at better-auth's own `/one-time-token/verify`, bound to
40314031
// the shell's PKCE challenge so an intercepted callback cannot be
40324032
// redeemed. Mounted unconditionally: with no registered scheme every
4033-
// target is refused with a 400 that says how to register one. A session
4034-
// signed in within the last five minutes is required, and a cross-site
4035-
// navigation is refused (ruling A); the reasons for every refusal live in
4033+
// target is refused with a 400 that says how to register one. Only a session
4034+
// signed in within the last five minutes is handed off, and a cross-site
4035+
// navigation is refused (ruling A). A page navigation without such a session
4036+
// goes to this deployment's sign-in page and comes back, so the app opens this
4037+
// route and never names the Console's. The reasons for every answer live in
40364038
// `one-time-token-handoff.ts`. Ledgered in auth-route-ledger.ts.
4039+
let reportedNoHandoffSignInPage = false;
40374040
rawApp.get(`${basePath}/one-time-token/handoff`, async (c: any) => {
40384041
try {
40394042
const manager = this.authManager!;
@@ -4050,6 +4053,25 @@ export class AuthPlugin implements Plugin {
40504053
const api: any = await manager.getApi();
40514054
return api.generateOneTimeToken({ headers });
40524055
},
4056+
signInEntry: (headers) =>
4057+
resolveHandoffSignInEntry({
4058+
signInPageUrl: manager.getConsoleUrl('/login'),
4059+
authBaseUrl: manager.getAuthIssuer(),
4060+
authMountPath: basePath,
4061+
requestUrl: c.req.url,
4062+
requestHost: headers.get('host'),
4063+
server: rawApp,
4064+
serverEnv: c.env,
4065+
onUnserved: (path, reason) => {
4066+
if (reportedNoHandoffSignInPage) return;
4067+
reportedNoHandoffSignInPage = true;
4068+
ctx.logger.warn(
4069+
`[AuthPlugin] one-time-token/handoff: this server does not serve a sign-in page at ${path} (${reason}), ` +
4070+
'so a signed-out browser that opens the mobile sign-in hand-off sees a JSON 401 instead of a sign-in form. ' +
4071+
'Mount the Console, or set uiBasePath to where it is mounted. Reported once.',
4072+
);
4073+
},
4074+
}),
40534075
},
40544076
{ queries: (c.req.queries() ?? {}) as Record<string, string[]>, headers: c.req.raw.headers },
40554077
);

‎packages/plugins/plugin-auth/src/auth-route-ledger.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -289,7 +289,7 @@ export const AUTH_ROUTE_LEDGER: readonly AuthRouteLedgerEntry[] = [
289289
// `one-time-token-handoff.ts` carries the interception argument.
290290
{ route: 'GET /api/v1/auth/one-time-token/generate', family: 'one-time-token', source: 'better-auth', disposition: 'server-only', requires: 'oneTimeToken', note: 'published by the catch-all and refused to every client request (disableClientRequest: 400 with a session, 401 without); the only caller is the in-process hand-off, which binds the token it mints to the app shell\'s PKCE challenge' },
291291
{ route: 'POST /api/v1/auth/one-time-token/verify', family: 'one-time-token', source: 'better-auth', disposition: 'public', requires: 'oneTimeToken', note: 'no SDK method builds this URL — the mobile app shell (or the Console in its WebView) redeems a hand-off token here with no session: body { token: "T.code_verifier" }, answering the vendor { session, user } plus the session cookie, once, within one minute; a token without the verifier whose challenge started the hand-off (an intercepted callback) answers the vendor 400 and consumes nothing' },
292-
{ route: 'GET /api/v1/auth/one-time-token/handoff', family: 'one-time-token', source: 'objectstack', disposition: 'server-only', requires: 'oneTimeToken', note: 'no SDK method builds this URL — the mobile app shell opens it in the system browser after a sign-in there; session required. Closed query set { redirect_uri, code_challenge, code_challenge_method }: answers 302 to a redirect_uri whose scheme trustedAppSchemes registers, with a single-use one-minute token bound to the S256 code_challenge; an unregistered scheme answers 400 with no redirect. The mount is unconditional; with no registered scheme every target is refused' },
292+
{ route: 'GET /api/v1/auth/one-time-token/handoff', family: 'one-time-token', source: 'objectstack', disposition: 'server-only', requires: 'oneTimeToken', note: 'no SDK method builds this URL — it is the entry the mobile app shell opens in the system browser. Closed query set { redirect_uri, code_challenge, code_challenge_method }: for a session signed in within five minutes it answers 302 to a redirect_uri whose scheme trustedAppSchemes registers, with a single-use one-minute token bound to the S256 code_challenge; an unregistered scheme answers 400 with no redirect. With no such session, a page navigation answers 302 to this deployment\'s Console sign-in page (root-relative, redirect naming the same request); any other caller, a navigation reporting Sec-Fetch-Site: same-origin (the sign-in page forwarding), and every caller on a deployment that serves no sign-in page answer 401. The mount is unconditional; with no registered scheme every target is refused' },
293293
{ route: 'POST /api/v1/auth/two-factor/disable', family: 'two-factor', source: 'better-auth', disposition: 'sdk', client: 'auth.twoFactor.disable', requires: 'twoFactor' },
294294
{ route: 'POST /api/v1/auth/two-factor/enable', family: 'two-factor', source: 'better-auth', disposition: 'sdk', client: 'auth.twoFactor.enable', requires: 'twoFactor' },
295295
{ route: 'POST /api/v1/auth/two-factor/generate-backup-codes', family: 'two-factor', source: 'better-auth', disposition: 'sdk', client: 'auth.twoFactor.generateBackupCodes', requires: 'twoFactor' },

0 commit comments

Comments
 (0)