You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit c6c2426
Browse filesBrowse the repository at this point in the historyBrowse files
feat(plugin-auth): export answerOneTimeTokenHandoff so a host that forwards the auth routes answers the mobile sign-in hand-off (#31464)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"}
-->
_Requested by **Leehom** · [project
thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XBK9EgGPfyQH8cn35jeTJe3)_
domain:server#6 · os-project-manager · session_01FEkQ5kNHZKmo4BTWcwTdNs
Fixes#31420
Clause-②: yes (widening)
Metadata-protocol: none
## 维护者速读
- 改了什么:`@objectstack/plugin-auth` 包根新导出一个函数
`answerOneTimeTokenHandoff(manager, request, serving)`
和三个类型(`OneTimeTokenHandoffManager`、`OneTimeTokenHandoffServing`、`HandoffAnswer`)。它就是手机登录交接路由
`GET /api/v1/auth/one-time-token/handoff` 的整段处理逻辑;AuthPlugin
自己的路由改为调用它,所以只有一份实现。
- 为什么改:cloud 托管环境的内核没有自己的 HTTP 服务器,`/api/v1/auth/*` 由宿主转发给环境内核的
AuthManager,这条交接路由在托管环境上一直是 404,托管环境上的用户无法用 Google 登录手机
App。宿主无法自己补:令牌必须在交接模块的 challenge
作用域内生成,作用域外生成的令牌按设计无法兑换。现在宿主调用这个导出即可,令牌仍在作用域内生成。
- 风险与回滚:AuthPlugin
自己挂的路由行为不变,原有交接测试一行未改、全部通过。新增的只是一个导出;生成令牌用的内部函数(`runOneTimeTokenHandoff`、`withHandoffChallenge`、`resolveHandoffSignInEntry`、令牌存储
hasher)仍不导出,有测试钉住。回滚即还原本 PR。
- 先看:`packages/plugins/plugin-auth/src/one-time-token-handoff.ts` 文件末尾的
`answerOneTimeTokenHandoff`;`auth-plugin.ts` 约 4713
行起的路由(现在只调用它);`index.ts` 新增的导出块。
- 两分钟验证:`pnpm --filter @objectstack/plugin-auth exec vitest run
src/one-time-token-handoff.test.ts -t "forwards the auth routes"`,预期 4
个用例通过:匿名探测 401、未登录页面导航 302 到宿主自己的登录页、登录后 302 到 App 且令牌凭 verifier
只能兑换一次、包根不导出内部函数。
- 需要你做的:无,席位落地。
## What changes
- **`one-time-token-handoff.ts`** gains
`answerOneTimeTokenHandoff(manager, request, serving)` with its two
input types, in the shape the card spells (the shape cloud#3549
measured): `OneTimeTokenHandoffManager` (`getRegisteredAppSchemes`,
`getApi`, `getConsoleUrl`, `getAuthIssuer`) and
`OneTimeTokenHandoffServing` (`authMountPath`, `server`, `serverEnv?`,
`onUnserved`); `request` is `{ url, headers, queries }`. Its body is the
route body that used to live in `auth-plugin.ts`:
`runOneTimeTokenHandoff` with `registeredSchemes`, `sessionCreatedAt`
and `generate` read off `manager`, and `signInEntry` from
`resolveHandoffSignInEntry` with `serving`. The module docblock gains a
short section, "One implementation, two mounts", saying why only the
whole answer is published.
- **`auth-plugin.ts`**: the `GET ${basePath}/one-time-token/handoff`
mount calls `answerOneTimeTokenHandoff(this.authManager, { url, headers,
queries }, { authMountPath: basePath, server: rawApp, serverEnv: c.env,
onUnserved })`. The once-only `warn`, the `302`/JSON translation and the
`500` catch are unchanged.
- **`index.ts`** (package root) exports `answerOneTimeTokenHandoff`,
`type OneTimeTokenHandoffManager`, `type OneTimeTokenHandoffServing` and
`type HandoffAnswer`, by name. `runOneTimeTokenHandoff`,
`withHandoffChallenge`, `resolveHandoffSignInEntry` and
`oneTimeTokenStoreHasher` stay unexported (order ruling).
- **`auth-route-ledger.ts`**: the `GET
/api/v1/auth/one-time-token/handoff` row's note says a host that
forwards the auth routes to an `AuthManager` it did not mount answers
this route through `answerOneTimeTokenHandoff`, which is this mount's
whole body.
- **Changeset** `.changeset/31420-one-time-token-handoff-export.md`:
`@objectstack/plugin-auth` patch, `Clause-②: yes (widening)` (the seat's
correction of the card's `no`: a new runtime export and three exported
types on a published entry), `Metadata-protocol: none`.
## Tests
One new top-level `describe` in the existing harness file
`one-time-token-handoff.test.ts`, "a host that forwards the auth routes
answers it through the package export". The host is a Hono app that is
not AuthPlugin's: no AuthPlugin is constructed, it serves its own
`/_console/*` page, answers the hand-off with
`answerOneTimeTokenHandoff` (imported from the package root, `./index`)
for a real `AuthManager` it was handed, and forwards every other
`/api/v1/auth/*` path to `manager.handleRequest`. Every request goes to
the host at `https://env-42.hosted.example`, an origin other than the
manager's `baseUrl`.
1. Anonymous JSON probe: `401`, `error.code` `UNAUTHENTICATED`, no
`Location`, nothing minted.
2. Signed-out page navigation: `302`, `Cache-Control: no-store`, a
root-relative `Location` to the host's own `/_console/login` with
`redirect` naming the same request; `onUnserved` never called.
3. Signed-in navigation: `302` to `com.example.mobile://…?token=T`;
`T.verifier` redeems at the host's `/one-time-token/verify` with `200`,
the user and the session cookie; the replay answers `400 Invalid token`.
Nothing written to `process.stdout`, `process.stderr`,
`console.log/info/debug/warn/error` or the host's `onUnserved` carries
`T`.
4. The package root exports `answerOneTimeTokenHandoff` and none of
`runOneTimeTokenHandoff`, `withHandoffChallenge`,
`resolveHandoffSignInEntry`, `oneTimeTokenStoreHasher`.
Every existing case in the file is unchanged (the file's import block
gains the root import).
<details><summary>Red first, at base 3d73c1d</summary>
New tests on the unchanged sources: 4 failed, 88 passed. Cases 1 to 3
answer `500` (the host's handler throws: the export does not exist),
case 4 `expected 'undefined' to be 'function'`.
</details>
<details><summary>Ablations (scripts/ablation-replace.mjs, committed
head 241314c, each restored with blob == HEAD and an empty git diff
HEAD)</summary>
The test imports sources (`./index`), so no `dist/` is on the resolution
path.
- **Mint outside the challenge scope.** In `runOneTimeTokenHandoff`,
`await withHandoffChallenge(challenge.value, () =>
deps.generate(request.headers))` replaced by `await
deps.generate(request.headers)` (anchor 1 → 0). Case 3 goes red on the
redeem row: `{"message":"Invalid token"}: expected 400 to be 200`; cases
1, 2, 4 stay green. Restored.
- **Publish a minting seam.** In `index.ts`,
`answerOneTimeTokenHandoff,` replaced by `answerOneTimeTokenHandoff as
answerOneTimeTokenHandoff, runOneTimeTokenHandoff,`. Case 4 goes red:
`runOneTimeTokenHandoff: expected [ 'DEFAULT_AUTH_BASE_PATH', …(164) ]
to not include 'runOneTimeTokenHandoff'`. Restored.
</details>
## Acceptance notes
- **Shape kept as the card spells it.** No deviation: the names and
members are the card's. `AuthManager` satisfies
`OneTimeTokenHandoffManager` structurally (its `getApi()` returns
`Promise<Auth['api']>`, assignable to `Promise<unknown>`), checked by
the package's `tsc --noEmit` through the AuthPlugin call site and by the
test's annotated `const handedManager: OneTimeTokenHandoffManager =
manager`.
- **`index.d.ts`.** After `pnpm --filter @objectstack/plugin-auth
build`, `dist/index.d.ts` and `dist/index.d.mts` each carry
`answerOneTimeTokenHandoff` and the three types;
`require('./dist/index.js')` answers `typeof answerOneTimeTokenHandoff
=== 'function'` and `false` for each of the four seams.
- **Serial constraint.** #30959 and #30967 also edit
`packages/plugins/plugin-auth/src/**`. Whichever lands later merges
`origin/main` once and re-runs the plugin-auth suite.
- **Governed surface.** `node scripts/pm/check-governed-merges.mjs
--branch HEAD`, exit 0: "0 of 6 path(s) hit the register … NOT
governed".
## Verification
All readings are on head `61481ad4` (the branch's fix commit `241314c6`
plus one merge of `origin/main` at `4941c40b`, which brought nine
commits including #31419's plugin-auth sign-in lockout work), after
`pnpm install --frozen-lockfile` and a rebuild of
`@objectstack/plugin-auth...` through `scripts/pm/os-verify-lock.sh`
(VERDICT command-exit 0). Each exit code is captured before any pipe.
- **plugin-auth suite**: `pnpm --filter @objectstack/plugin-auth test`,
VERDICT command-exit 0, Test Files 240 passed (240), Tests 4271 passed,
10 skipped.
- **plugin-auth typecheck**: `pnpm --filter @objectstack/plugin-auth
typecheck` (`tsc --noEmit`, the examples project, and
`check:test-typecheck`), VERDICT command-exit 0; the test layer holds at
its recorded debt (10 files, 88 errors, 23 pinned signatures, none new).
- **Lint**: `eslint packages/plugins/plugin-auth --no-inline-config`,
exit 0.
- **Derived gate union**: `node scripts/pm/dispatch-gates.mjs --commands
--repo objectstack-ai/objectstack` derived 75 families for this diff;
all 75 ran and exited 0. `--ran`: "75 derived famil(ies) accounted for —
75 run, 0 NOT-MEASURED". Among them `check:auth-mount-ledger`,
`check:route-envelope`, `check:dts-closure`,
`check:dual-build-cjs-loads` (122 require entry points across 72
packages load), `check:test-source-alias` and `check:type-check-debt`.
<details><summary>First pass on 241314c, before the merge</summary>
Same suite, typecheck and lint, all exit 0 (Tests 4252 passed, 10
skipped). The union: 74 of 75 exit 0. `check:plugin-teardown-shape
--self-test` first answered exit 3 on the shallow clone and exit 0 after
`git fetch --depth=1` of its fixture commit;
`check:dual-build-cjs-loads` answered exit 3, PREREQUISITE NOT MET (not
every package built). Both are exit 0 on the final head above.
</details>
The `test:repo` project, `Test Core` shards and the dogfood battery are
CI's; this PR's check-runs carry them. The title has no `!` and
`Clause-②` has no `(narrowing)` arm, so no local dogfood run is owed.
## Human Minimum
- [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic.
(not touched)
- [x] 2. Every name I added uses the casing for its kind.
- [x] 3. No formatter ran; `pnpm lint` passes. (scoped to plugin-auth,
exit 0)
- [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: its generated artifacts are
regenerated and committed. (not touched)
- [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 维护者速读 block is
the lane's required Chinese summary)
- [x] The *Before Submitting* list in CONTRIBUTING.md is done.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01FEkQ5kNHZKmo4BTWcwTdNs
---
_Generated by [Claude
Code](https://claude.ai/code/session_01FEkQ5kNHZKmo4BTWcwTdNs)_
Co-authored-by: Claude <noreply@anthropic.com>
feat(plugin-auth): `answerOneTimeTokenHandoff` — a host that forwards `/api/v1/auth/*` to a kernel's `AuthManager` answers the mobile sign-in hand-off
6
+
7
+
Clause-②: yes (widening)
8
+
Metadata-protocol: none
9
+
10
+
The package root now exports `answerOneTimeTokenHandoff(manager, request, serving)` with its input types `OneTimeTokenHandoffManager` and `OneTimeTokenHandoffServing`, and `HandoffAnswer` as a type beside them. It is the whole body of `GET /api/v1/auth/one-time-token/handoff`: AuthPlugin's own mount now calls it, and its answers do not change.
11
+
12
+
A host that serves no AuthPlugin routes and forwards the auth routes to an `AuthManager` it did not mount (a per-environment kernel with no HTTP server, AuthPlugin with `registerRoutes: false`) used to answer the hand-off `404`, because the route existed only on AuthPlugin's own server and a token minted outside the hand-off is unredeemable by design. Such a host now answers it for the manager it forwards to: `answerOneTimeTokenHandoff(manager, { url, headers, queries }, { authMountPath: '/api/v1/auth', server, serverEnv, onUnserved })`. A `302` answer carries `headers` and no body; every other status carries `body` as JSON with `headers`. The sign-in page a signed-out navigation is sent to is the one `server` answers, and `Location` stays root-relative, so the browser stays on the host it arrived on. The seams the hand-off is built from (`runOneTimeTokenHandoff`, `withHandoffChallenge`, `resolveHandoffSignInEntry`, the token-store hasher) stay unexported, so no host can mint outside the challenge scope.
// `one-time-token-handoff.ts` carries the interception argument.
313
313
{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'},
314
314
{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'},
315
-
{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'},
315
+
{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. A host that forwards the auth routes to an AuthManager it did not mount (a per-environment kernel with no HTTP server, so this mount never runs) answers this route through the package export answerOneTimeTokenHandoff, which is this mount\'s whole body: the token is still minted inside the challenge scope, and the hand-off\'s seams stay unexported'},
0 commit comments