Skip to content

Commit c6c2426

Browse 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>
1 parent f6a429b commit c6c2426

6 files changed

Lines changed: 263 additions & 35 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
---
4+
5+
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.

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

Lines changed: 22 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ import { runSetInitialPassword } from './set-initial-password.js';
103103
import { runRegisterSsoProviderFromForm, runRegisterSamlProviderFromForm, runRequestDomainVerification, runVerifyDomain, type AuthRequestHandler } from './register-sso-provider.js';
104104
import { runResendVerificationEmail } from './send-verification-email.js';
105105
import { runSysSessionRevoke, sysSessionRevokeDepsFor } from './sys-session-revoke.js';
106-
import { resolveHandoffSignInEntry, runOneTimeTokenHandoff } from './one-time-token-handoff.js';
106+
import { answerOneTimeTokenHandoff } from './one-time-token-handoff.js';
107107
import { readJsonRequestBody } from './request-json-body.js';
108108
import {
109109
seedCreatedOrganizationCurrency,
@@ -4708,45 +4708,33 @@ export class AuthPlugin implements Plugin {
47084708
// navigation is refused (ruling A). A page navigation without such a session
47094709
// goes to this deployment's sign-in page and comes back, so the app opens this
47104710
// route and never names the Console's. The reasons for every answer live in
4711-
// `one-time-token-handoff.ts`. Ledgered in auth-route-ledger.ts.
4711+
// `one-time-token-handoff.ts`, whose `answerOneTimeTokenHandoff` is this
4712+
// route's whole body and the package export a host that forwards the auth
4713+
// routes to a manager it did not mount calls. Ledgered in auth-route-ledger.ts.
47124714
let reportedNoHandoffSignInPage = false;
47134715
rawApp.get(`${basePath}/one-time-token/handoff`, async (c: any) => {
47144716
try {
4715-
const manager = this.authManager!;
4716-
const answer = await runOneTimeTokenHandoff(
4717+
const answer = await answerOneTimeTokenHandoff(
4718+
this.authManager!,
47174719
{
4718-
registeredSchemes: manager.getRegisteredAppSchemes(),
4719-
sessionCreatedAt: async (headers) => {
4720-
const api: any = await manager.getApi();
4721-
const live = await api.getSession({ headers });
4722-
const createdAt = live?.session?.createdAt;
4723-
return createdAt ? new Date(createdAt) : null;
4724-
},
4725-
generate: async (headers) => {
4726-
const api: any = await manager.getApi();
4727-
return api.generateOneTimeToken({ headers });
4720+
url: c.req.url,
4721+
headers: c.req.raw.headers,
4722+
queries: (c.req.queries() ?? {}) as Record<string, string[]>,
4723+
},
4724+
{
4725+
authMountPath: basePath,
4726+
server: rawApp,
4727+
serverEnv: c.env,
4728+
onUnserved: (path, reason) => {
4729+
if (reportedNoHandoffSignInPage) return;
4730+
reportedNoHandoffSignInPage = true;
4731+
ctx.logger.warn(
4732+
`[AuthPlugin] one-time-token/handoff: this server does not serve a sign-in page at ${path} (${reason}), ` +
4733+
'so a signed-out browser that opens the mobile sign-in hand-off sees a JSON 401 instead of a sign-in form. ' +
4734+
'Mount the Console, or set uiBasePath to where it is mounted. Reported once.',
4735+
);
47284736
},
4729-
signInEntry: (headers) =>
4730-
resolveHandoffSignInEntry({
4731-
signInPageUrl: manager.getConsoleUrl('/login'),
4732-
authBaseUrl: manager.getAuthIssuer(),
4733-
authMountPath: basePath,
4734-
requestUrl: c.req.url,
4735-
requestHost: headers.get('host'),
4736-
server: rawApp,
4737-
serverEnv: c.env,
4738-
onUnserved: (path, reason) => {
4739-
if (reportedNoHandoffSignInPage) return;
4740-
reportedNoHandoffSignInPage = true;
4741-
ctx.logger.warn(
4742-
`[AuthPlugin] one-time-token/handoff: this server does not serve a sign-in page at ${path} (${reason}), ` +
4743-
'so a signed-out browser that opens the mobile sign-in hand-off sees a JSON 401 instead of a sign-in form. ' +
4744-
'Mount the Console, or set uiBasePath to where it is mounted. Reported once.',
4745-
);
4746-
},
4747-
}),
47484737
},
4749-
{ queries: (c.req.queries() ?? {}) as Record<string, string[]>, headers: c.req.raw.headers },
47504738
);
47514739
if (answer.status === 302) {
47524740
return new Response(null, { status: 302, headers: answer.headers });

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -312,7 +312,7 @@ export const AUTH_ROUTE_LEDGER: readonly AuthRouteLedgerEntry[] = [
312312
// `one-time-token-handoff.ts` carries the interception argument.
313313
{ 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' },
314314
{ 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' },
316316
{ route: 'POST /api/v1/auth/two-factor/disable', family: 'two-factor', source: 'better-auth', disposition: 'sdk', client: 'auth.twoFactor.disable', requires: 'twoFactor' },
317317
{ route: 'POST /api/v1/auth/two-factor/enable', family: 'two-factor', source: 'better-auth', disposition: 'sdk', client: 'auth.twoFactor.enable', requires: 'twoFactor' },
318318
{ route: 'POST /api/v1/auth/two-factor/generate-backup-codes', family: 'two-factor', source: 'better-auth', disposition: 'sdk', client: 'auth.twoFactor.generateBackupCodes', requires: 'twoFactor' },

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,18 @@ export {
4040
// kept; the call never throws. The pure steps of the rule stay unexported, so
4141
// the rule keeps one owner.
4242
export type { IdpLocaleClaim } from './idp-locale-claim.js';
43+
// The mobile sign-in hand-off, for a host that forwards `/api/v1/auth/*` to a
44+
// kernel's AuthManager it did not mount (a per-environment kernel with no HTTP
45+
// server): that host answers `GET /one-time-token/handoff` with this, the same
46+
// body AuthPlugin's own mount runs. Only the whole answer is published: the
47+
// token must be minted inside the hand-off's challenge scope, so the seams it
48+
// is built from stay unexported and no host can mint outside it.
49+
export {
50+
answerOneTimeTokenHandoff,
51+
type OneTimeTokenHandoffManager,
52+
type OneTimeTokenHandoffServing,
53+
type HandoffAnswer,
54+
} from './one-time-token-handoff.js';
4355
export * from './ensure-default-organization.js';
4456
// [ADR-0131 D3 / D10 fate 3] The Default Organization's boot invariant, for the
4557
// v18 organization-ownership ceremony (`os migrate organization-ownership

‎packages/plugins/plugin-auth/src/one-time-token-handoff.test.ts‎

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,13 @@ import {
4040
runOneTimeTokenHandoff,
4141
s256Challenge,
4242
} from './one-time-token-handoff';
43+
import * as packageRoot from './index';
44+
import {
45+
answerOneTimeTokenHandoff,
46+
type HandoffAnswer,
47+
type OneTimeTokenHandoffManager,
48+
type OneTimeTokenHandoffServing,
49+
} from './index';
4350

4451
const SECRET = 'test-secret-at-least-32-chars-long!!';
4552
const PASSWORD = 'S3cure!Passw0rd-one-time-token';
@@ -732,3 +739,128 @@ describe('trustedAppSchemes — refused at construction, never dropped', () => {
732739
expect(manager.getRegisteredAppSchemes()).toEqual([APP_SCHEME]);
733740
});
734741
});
742+
743+
// A host that forwards `/api/v1/auth/*` to a kernel's `AuthManager` it did not
744+
// mount (the cloud's per-environment kernels: no `http-server`, AuthPlugin with
745+
// `registerRoutes: false`) answers the hand-off through the package root's one
746+
// export, so the token is still minted inside the challenge scope and the
747+
// AuthPlugin route and every such host share one implementation.
748+
describe('one-time-token hand-off — a host that forwards the auth routes answers it through the package export', () => {
749+
const query = { redirect_uri: CALLBACK, code_challenge: s256Challenge(VERIFIER), code_challenge_method: 'S256' };
750+
const HOST_ORIGIN = 'https://env-42.hosted.example';
751+
752+
/**
753+
* The host: a Hono app that is NOT AuthPlugin's. It serves its own sign-in
754+
* page, answers the hand-off for the manager it was handed, and forwards every
755+
* other auth path to that manager the way a forwarding host does. No
756+
* AuthPlugin is constructed, so nothing here mounts `registerAuthRoutes`.
757+
*/
758+
const arrangeHost = async () => {
759+
const engine = createMemoryEngine();
760+
for (const [name, rows] of await signedUpOnce()) engine.tables.set(name, structuredClone(rows));
761+
const manager = makeManager(engine);
762+
const unserved: string[] = [];
763+
const host = new Hono();
764+
servedConsole(host);
765+
host.get(`${BASE}${ONE_TIME_TOKEN_HANDOFF_PATH}`, async (c) => {
766+
const handedManager: OneTimeTokenHandoffManager = manager;
767+
const serving: OneTimeTokenHandoffServing = {
768+
authMountPath: BASE,
769+
server: host,
770+
serverEnv: c.env,
771+
onUnserved: (path, reason) => unserved.push(`${path}: ${reason}`),
772+
};
773+
const answer: HandoffAnswer = await answerOneTimeTokenHandoff(
774+
handedManager,
775+
{ url: c.req.url, headers: c.req.raw.headers, queries: c.req.queries() ?? {} },
776+
serving,
777+
);
778+
if (answer.status === 302) return new Response(null, { status: 302, headers: answer.headers });
779+
return c.json(answer.body, answer.status as any, answer.headers);
780+
});
781+
host.all(`${BASE}/*`, (c) => manager.handleRequest(c.req.raw));
782+
783+
const viaHost = (path: string, init: RequestInit = {}) => host.request(`${HOST_ORIGIN}${path}`, init);
784+
const handoff = (cookie: string | undefined, extra: Record<string, string> = {}) =>
785+
viaHost(`${BASE}${ONE_TIME_TOKEN_HANDOFF_PATH}?${new URLSearchParams(query)}`, {
786+
headers: { ...(cookie ? { cookie } : {}), ...extra },
787+
});
788+
const signIn = async (): Promise<string> => {
789+
const res = await viaHost(`${BASE}/sign-in/email`, {
790+
method: 'POST',
791+
headers: { 'Content-Type': 'application/json', origin: ORIGIN },
792+
body: JSON.stringify({ email: ALICE_EMAIL, password: PASSWORD }),
793+
});
794+
expect(res.status, `sign-in: ${await res.clone().text()}`).toBe(200);
795+
return cookieFrom(res);
796+
};
797+
const redeem = (token: string) =>
798+
viaHost(`${BASE}${ONE_TIME_TOKEN_VERIFY_PATH}`, {
799+
method: 'POST',
800+
headers: { 'Content-Type': 'application/json', origin: ORIGIN },
801+
body: JSON.stringify({ token }),
802+
});
803+
return { engine, unserved, handoff, signIn, redeem };
804+
};
805+
806+
it('answers the app\'s anonymous probe with 401 UNAUTHENTICATED and mints nothing', async () => {
807+
const { engine, handoff } = await arrangeHost();
808+
const before = verificationRows(engine).length;
809+
const res = await handoff(undefined, { accept: 'application/json' });
810+
expect(res.status).toBe(401);
811+
expect(((await res.json()) as any).error.code).toBe('UNAUTHENTICATED');
812+
expect(res.headers.get('location')).toBeNull();
813+
expect(verificationRows(engine).length).toBe(before);
814+
});
815+
816+
it('sends a signed-out navigation to the HOST\'s own sign-in page, root-relative, naming the same request', async () => {
817+
const { handoff, unserved } = await arrangeHost();
818+
const res = await handoff(undefined, NAVIGATION);
819+
expect(res.status).toBe(302);
820+
expect(res.headers.get('cache-control')).toBe('no-store');
821+
const location = res.headers.get('location')!;
822+
expect(location.startsWith('/')).toBe(true);
823+
const target = new URL(location, HOST_ORIGIN);
824+
expect(target.pathname).toBe('/_console/login');
825+
expect(target.searchParams.get('redirect')).toBe(`${BASE}${ONE_TIME_TOKEN_HANDOFF_PATH}?${new URLSearchParams(query)}`);
826+
expect(unserved).toEqual([]);
827+
});
828+
829+
it('hands a fresh session to the app, the token redeems at /one-time-token/verify with the verifier once, and no log line carries it', async () => {
830+
const streams = [process.stdout, process.stderr].map((stream) => vi.spyOn(stream, 'write'));
831+
const consoles = (['log', 'info', 'debug', 'warn', 'error'] as const).map((level) =>
832+
vi.spyOn(console, level).mockImplementation(() => {}),
833+
);
834+
const { engine, signIn, handoff, redeem, unserved } = await arrangeHost();
835+
const cookie = await signIn();
836+
837+
const res = await handoff(cookie, NAVIGATION);
838+
expect(res.status).toBe(302);
839+
const location = new URL(res.headers.get('location')!);
840+
expect(location.protocol).toBe(`${APP_SCHEME}:`);
841+
const token = location.searchParams.get('token')!;
842+
expect(token).toMatch(/^[A-Za-z0-9_-]{32}$/);
843+
844+
const redeemed = await redeem(`${token}.${VERIFIER}`);
845+
expect(redeemed.status, await redeemed.clone().text()).toBe(200);
846+
expect(((await redeemed.json()) as any).user.id).toBe(userRows(engine).find((r) => r.email === ALICE_EMAIL).id);
847+
expect(cookieFrom(redeemed)).toContain('session_token=');
848+
849+
const replay = await redeem(`${token}.${VERIFIER}`);
850+
expect(replay.status).toBe(400);
851+
expect(((await replay.json()) as any).message).toBe('Invalid token');
852+
853+
const written = [...streams, ...consoles]
854+
.flatMap((spy) => spy.mock.calls)
855+
.map((args) => args.map((arg) => (typeof arg === 'string' ? arg : String(arg))).join(' '))
856+
.concat(unserved);
857+
expect(written.filter((line) => line.includes(token))).toEqual([]);
858+
});
859+
860+
it('the package root publishes the one answer and none of the minting seams', () => {
861+
expect(typeof packageRoot.answerOneTimeTokenHandoff).toBe('function');
862+
for (const seam of ['runOneTimeTokenHandoff', 'withHandoffChallenge', 'resolveHandoffSignInEntry', 'oneTimeTokenStoreHasher']) {
863+
expect(Object.keys(packageRoot), seam).not.toContain(seam);
864+
}
865+
});
866+
});

0 commit comments

Comments
 (0)