Repository navigation
feat(plugin-auth): mobile sign-in hand-off over better-auth's one-time-token plugin, bound to the shell's PKCE challenge - #29766
Conversation
…e-token plugin (WIP) Claude-Session: https://claude.ai/code/session_01JQwxbZkE4E6sE72j23Afwx Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JQwxbZkE4E6sE72j23Afwx Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 23 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 6 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 22 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin a0db5771135f9aae66f6561b375ba6616a36539d && git checkout a0db5771135f9aae66f6561b375ba6616a36539d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin bb2c04e3ca0158beff0cec7a1ffa211b452082d2 01e2f33e629b4823858b7ef28b8afe7c2692964c && git checkout -B drift-repro bb2c04e3ca0158beff0cec7a1ffa211b452082d2 && git merge --no-ff 01e2f33e629b4823858b7ef28b8afe7c2692964c
node scripts/docs-audit/affected-docs.mjs --json bb2c04e3ca0158beff0cec7a1ffa211b452082d2
|
…-manager plugin inventories Claude-Session: https://claude.ai/code/session_01JQwxbZkE4E6sE72j23Afwx Co-authored-by: Claude <noreply@anthropic.com>
…in five minutes, and refuses a cross-site navigation Claude-Session: https://claude.ai/code/session_01JQwxbZkE4E6sE72j23Afwx Co-authored-by: Claude <noreply@anthropic.com>
…oute names from the spec The spec slice (#29736) landed AuthConfig.trustedAppSchemes, TrustedAppSchemeSchema and AuthEndpointPaths.oneTimeTokenHandoff/oneTimeTokenVerify. appSchemeRefusal now runs the imported schema instead of a local copy of its rule (the local copy accepted an empty or trailing label: com..app, com.example.), the temporary AuthManagerOptions.trustedAppSchemes declaration is gone, the two path constants derive from the spec, and the changeset states ruling A and the stricter rule. Claude-Session: https://claude.ai/code/session_01JQwxbZkE4E6sE72j23Afwx Co-authored-by: Claude <noreply@anthropic.com>
|
domain:server#2 · os-warren · session_01JQwxbXKRzgnVvjzxaYxLUf 维护者速读
席位核对ACCEPT ·
Generated by Claude Code |
Requested by Warren · project thread
Fixes #29714
Clause-②: yes (widening)
Metadata-protocol: none
Clause-② is
yes (widening)because three auth routes become reachable that were 404 onmain: the ObjectStack hand-off and the vendor plugin's generate and verify. Nothing an author writes changes, soMetadata-protocol: none. The spec half (route paths, the request shapes, and thetrustedAppSchemeskey) is #29736, and this PR stays draft until #29736 lands.维护者速读
GET /one-time-token/handoff路由:系统浏览器里登录后,把会话以一次性、一分钟有效的令牌交给已注册的 app scheme,App 在/one-time-token/verify兑换。trustedAppSchemes时才挂载插件,回滚即删掉该配置或回退本 PR。an intercepted callback cannot be redeemed」证明截获的令牌被拒且正主随后仍能兑换,「ruling Aone-time-token-handoff.ts(顶部注释是拦截论证,oneTimeTokenStoreHasher);auth-manager-plugin-list.ts新增的插件块;auth-plugin.ts的 handoff 挂载。pnpm --filter @objectstack/plugin-auth exec vitest run src/one-time-token-handoff.test.ts,预期 39/39 通过,其中「an intercepted callback cannot be redeemed」证明截获的令牌被拒且正主随后仍能兑换,「ruling A」一组证明超过五分钟的会话和跨站导航被拒。What changes
The flow (the mobile card designs the shell half; this PR is the server half):
code_verifierand keeps it.ASWebAuthenticationSession, Custom Tabs), after a sign-in there, the shell opensGET /api/v1/auth/one-time-token/handoff?redirect_uri=SCHEME://PATH&code_challenge=C&code_challenge_method=S256, whereC = BASE64URL(SHA-256(code_verifier)).302toredirect_uriwithtoken=Tadded (Cache-Control: no-store,Referrer-Policy: no-referrer; the shell's own query, such asstate, is kept).POST /api/v1/auth/one-time-token/verifywith{ "token": "T.code_verifier" }and receives the vendor{ session, user }and the session cookie.The plugin,
oneTimeToken({ expiresIn: 1, disableClientRequest: true, storeToken: { type: 'custom-hasher', hash } }), added throughaddOptionalPluginahead of the custom session, mounted only whentrustedAppSchemesregisters at least one scheme.one-time-token:plus a SHA-256 key; neither the token nor the challenge is in the row.disableClientRequestrefuses every HTTP request to/one-time-token/generate(400 with a session, 401 without). The hand-off callsauth.api.generateOneTimeToken({ headers }), which carries no request, so it passes.How an intercepted callback cannot be redeemed. The vendor plugin by itself is not bound to the attempt that started the sign-in: whoever holds
Tredeems it, and on a phone another app can register the same custom scheme and receive the callback. The binding uses only the plugin's publishedstoreTokenoption:AsyncLocalStoragescope that holdsC, and the custom hasher stores the session underSHA-256(T ":" C).T.code_verifier(the vendor alphabet forThas no., so the first.is the separator) and looks upSHA-256(T ":" S256(code_verifier)).Talone.T,T.,Twith a guessed verifier, orTwith the challenge itself all hash to a key no row has, so the vendor answers its own400 Invalid token. Because the lookup used the wrong key, nothing is consumed and the shell that started the hand-off still redeems. The verifier never leaves the device; only its hash travels, in step 2.The registered-scheme list is the spec's
AuthConfig.trustedAppSchemes(#29736, besidetrustedOrigins); there is no local declaration. Each entry is checked by the spec's ownTrustedAppSchemeSchema, imported intoappSchemeRefusaland never copied, because the auth manager also receives plain objects that never passedAuthConfigSchema: lowercase reverse-domain private-use schemes with no:suffix and no empty or trailing label (RFC 8252 §7.1, for examplecom.example.app;com..appandcom.example.are refused).http,https,javascript,data,file,blob,about,vbscript,wsandwssget a named "web or script scheme" refusal first; none of them contains a dot, so the schema refuses each anyway, and that check exists only for the clearer message. A bad entry failsAuthManagerconstruction andapplyConfigPatch, naming the entry; it is never dropped. Aredirect_uriwhose scheme is not registered (matched case-insensitively) answers400 VALIDATION_ERRORwith noLocation, so the route is never an open redirect. The route pathsONE_TIME_TOKEN_HANDOFF_PATHandONE_TIME_TOKEN_VERIFY_PATHderive fromAuthEndpointPaths.oneTimeTokenHandoff/oneTimeTokenVerify.Correction to the earlier body: it said the local scheme check applied "the same rules" as the spec. It did not: the local regex
^[a-z][a-z0-9+.-]*$plus "contains a dot" acceptedcom..appandcom.example., which the spec refuses. The import closes that gap, and the two entries are now pinned.The hand-off route's other answers. It accepts a closed query set (
redirect_uri,code_challenge,code_challenge_method, each at most once). An unknown or repeated name, a missing or relativeredirect_uri, a missing or malformed challenge, or a method other thanS256answers400 VALIDATION_ERRORbefore anything is minted. A signed-out caller gets401 UNAUTHENTICATED. The mount is unconditional: with no scheme registered, every target is refused with a 400 that says how to register one.The hand-off carries only a fresh sign-in (ruling A, 6597001931). Two refusals run before any token is minted:
session.createdAtis more than five minutes old (ONE_TIME_TOKEN_HANDOFF_SESSION_MAX_AGE_SECONDS = 300) answers401 UNAUTHENTICATED"Sign in again…". 401 because the remedy is a sign-in, the same as having no session.Sec-Fetch-Site: cross-siteanswers400 VALIDATION_ERROR. The header is checked only when present;none,same-origin,same-siteand an absent header pass.Ledger rows. In
auth-route-ledger.ts(familyone-time-token,requires: 'oneTimeToken'):GET /one-time-token/handoff:objectstack,server-only.POST /one-time-token/verify:better-auth,public.GET /one-time-token/generate:better-auth,server-only.The two vendor routes are added to
BETTER_AUTH_MOUNTED_SURFACE. The conformance suite registers one app scheme so the maximal surface carries them, and its objectstack-mount pin gains the hand-off.check:auth-mount-ledgeraccounts for all 24 mounts. No route-envelope row applies:check:route-envelopeaudits*-routes.tsmodules and dispatcher domains, and the hand-off is a raw auth mount like its siblings, answering the shared{ success: false, error: { code, message } }envelope on every refusal.Why
Some identity providers (Google among them) refuse embedded WebViews, so a mobile user who signs in with one cannot sign in to the app today (objectstack-ai/mobile#176 detects the refusal). The maintainer ruled option B on objectstack-ai/mobile#148 (comment 6593861138, 「同意」): reuse better-auth's one-time-token plugin, with no hand-written exchange endpoint. Verify is the vendor's route, unchanged.
Tests
packages/plugins/plugin-auth/src/one-time-token-handoff.test.ts(39 cases) runs the realAuthManager(better-auth 1.7.12 and its plugin) over the package's shared in-memory engine, with the hand-off through the realregisterAuthRoutesraw-app registration, the waysys-session-revoke.test.tsdoes.302to the registered scheme withstatekept, no-store and no-referrer. The redeem returns Alice's user and a session cookie. A replay answers400 Invalid token.T,T.,T.plus a guessed verifier, andT.plus the challenge each answer400 Invalid tokenwith no cookie. The realT.code_verifierthen answers200.sys_verificationrow's identifier holds neitherTnorC, and equals the key the redemption computes.400.400 Client requests are disabledwith a session and401without. A server-side mint outside the hand-off is unredeemable.com.attacker.app://,https://andjavascript:answer400 VALIDATION_ERRORwith noLocation. A mixed-case registered scheme matches. With no scheme registered, verify is404and the hand-off refuses every target. A signed-out caller gets401 UNAUTHENTICATED.VALIDATION_ERROR, and generate is never called.401 UNAUTHENTICATED, with noLocationand no new verification row; one five seconds inside it answers302.Sec-Fetch-Site: cross-siteanswers400 VALIDATION_ERROR;none,same-origin,same-siteand no header answer302.trustedAppSchemes. Twelve bad entries (the earlier ten pluscom..appandcom.example., which the spec's schema refuses) are refused by the assertion, by construction and byapplyConfigPatch.Acceptance notes
SameSite=Lax, so any web page could start the hand-off with a top-level GET navigation, and on Android an app squatting the scheme would then need no user action at all. Browsers that send Fetch Metadata mark that navigationSec-Fetch-Site: cross-site, and the hand-off refuses it. A browser that sends no Fetch Metadata is not covered by this refusal and still meets refusal 1. Option B (an interstitial that POSTs) is the only design that closes the trigger in every browser, and it was not chosen./sign-in/emailand its siblings, and no session row is created, sosys_sessionand the sign-in counters do not move. Noted, not filed.pnpm lint. os-dev's local-verification section assigns repo-wide lint to CI. I ran eslint on every touched file (exit 0) and also ran the fullpnpm lint; its result is under Verification.Verification
Measured before and after (better-auth 1.7.12, plugin-auth on main 87956dc, then this branch)
GET /one-time-token/generateanonymous answers 404, and signed in also 404.POST /one-time-token/verifyanswers 404.auth.api.generateOneTimeTokenis undefined, so the plugin is not mounted.UNAUTHORIZED. Signed in, it answers 400Client requests are disabled. Verify with a bogus token answers 400Invalid token. The hand-off round trip, interception refusal, replay and expiry are as pinned above.dist/plugins/one-time-token/index.mjshasexpiresIn ?? 3minutes andstoreTokenofplain,hashed, or{ type: 'custom-hasher', hash }.disableClientRequestrefuses whenc.requestis set. Generate isGETbehindsessionMiddleware, and verify is aPOSTwith body{ token }that callsconsumeVerificationValueonone-time-token:plus the hash.Tests, typecheck, ablation (head 01e2f33, main merged at 7cbd845 with #29804)
git diff dead38d4 01e2f33eempty), so every figure below holds for the head.pnpm --filter @objectstack/plugin-auth test, Test Files 217 passed, Tests 3747 passed, 10 skipped. New file alone: 39 passed.pnpm --filter @objectstack/plugin-auth... buildthenpnpm --filter @objectstack/plugin-auth typecheck(src, examples and the test layer), under the lock:VERDICT command-exit 0.scripts/ablation-replace.mjs, restored with an emptygit diff HEAD:Talone (the vendor'shashed, no binding): the interception pin and the outside-scope pin went red;appSchemeRefusalput back on the old local rule (^[a-z][a-z0-9+.-]*$plus a dot): exactlyrefuses "com..app"andrefuses "com.example."went red (2 failed, 37 passed), blob ad5f8e97 to 68fa61b9 and restored to the HEAD blob.Gates (head 01e2f33)
node scripts/pm/dispatch-gates.mjs --commandsderived the same 64 families as on 766bb72 (sorted lists identical). All 64 ran with exit 0, includingpnpm check:dual-build-cjs-loads(115 require entry points across 70 packages load), which was NOT MEASURED on 766bb72.--ranreconciliation exit 0: "64 derived famil(ies) accounted for — 64 run, 0 NOT-MEASURED".node scripts/check-plugin-teardown-shape.mjs --self-test: exit 0 (its fixture commit fetched as before).pnpm lint(repo-wide eslint): exit 0.Human Minimum
packages/specholds no runtime logic (nopackages/specpath in this diff; the spec half is spec(auth): declare the mobile sign-in hand-off surface (one-time-token generate/verify paths, the registered app-scheme list, any advertised feature flag) before #29714 enables it #29736).pnpm lintpasses.main, one pull request.packages/spectouched: not touched.content/docs/references/,packages/*/CHANGELOG.mdorcontent/docs/releases/.Generated by Claude Code
🤖 Generated with Claude Code
https://claude.ai/code/session_01JQwxbZkE4E6sE72j23Afwx