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
plugin-auth: enable better-auth's one-time-token plugin so the mobile shell can hand a system-browser sign-in back to its WebView #29714
Filing category ③: a direct task from a maintainer ruling. objectstack-ai/mobile#148 comment 6593861138: Ruling: batch #1 item 4 · letter B · maintainer 「同意」. Option B reuses the auth library's existing plugin instead of hand-writing a code-exchange endpoint.
Why
Some identity providers (Google among them) refuse embedded WebViews. A mobile user who signs in with such a provider cannot sign in to the app today: objectstack-ai/mobile#176 detects the refusal and shows "not supported in the app yet".
The ruled flow:
The shell opens the sign-in in the system auth session (ASWebAuthenticationSession on iOS, Custom Tabs on Android).
The server issues a single-use, short-lived code to the app scheme.
The Console in the WebView exchanges that code for its own session.
The ruling's premise, measured by the seat
better-auth@1.7.12 is the version packages/plugins/plugin-auth pins. Its npm tarball ships ./plugins/one-time-token, with these endpoints:
/one-time-token/generate: an authenticated session issues a token;
/one-time-token/verify: the token is exchanged for a session.
Read from dist/plugins/one-time-token:
default expiry 3 minutes (expiresIn ?? 3);
storeToken: "hashed" is available;
disableClientRequest exists.
On objectstack main, git grep -nE "oneTimeToken|@better-auth/expo" -- packages/plugins/plugin-auth returns 0 hits, so the plugin is not enabled today.
Ask
Enable the plugin in plugin-auth's plugin list:
hashed storage;
a short expiry (state the value and why);
generation only by a server-side redirect handler, not by a client request, if disableClientRequest supports that shape.
Add the redirect step. After the provider sign-in completes in the system browser, the server redirects to the app scheme callback with the token. The mobile card designs the shell half; this card only needs the server to emit the redirect for a registered app scheme.
Interception (human floor): the plugin is not PKCE-bound to the attempt that started the sign-in. The PR must state how a callback intercepted by another app on the device is kept from being redeemed. One option is to bind the token to a verifier the shell generated, sent at the start and required at verify. The maintainer reviews and merges this PR.
Declare the new public surface in packages/spec, as the contract-first rule requires. The spec lane owns that half.
Who acts
The domain:server seat, with the spec half to the domain:spec seat. Triage first-touch decides the grade.
Duplicate check (2026-11-03)
Query
Hits
is:issue oneTimeToken plugin
0
is:issue one-time-token
2121 tokenised hits, none on this plugin in the first page
Read fresh. This enables better-auth's one-time-token plugin so the mobile shell can hand a system-browser sign-in back to its WebView. Today a user whose provider refuses embedded WebViews cannot sign in on mobile. A missing capability, ruled B by the maintainer, so p2.
Contract first: the new public surface is declared in packages/spec by a domain:spec card or PR before the server PR goes ready. The server seat files it at claim, unless the spec lane takes it from here.
Human floor: an auth boundary. The PR states how an intercepted callback is kept from being redeemed (for example, a verifier bound at start), and the maintainer reviews and merges it. Any security finding goes to the security lane, not to a public comment.
{
"issue": 29714,
"status": "done",
"branch": "claude/project-thread-p4ozlw",
"pr": "https://github.com/objectstack-ai/objectstack/pull/29766",
"session": "session_01JQwxbZkE4E6sE72j23Afwx",
"premise_still_valid": true,
"summary": "plugin-auth enables better-auth 1.7.12's one-time-token plugin (expiresIn 1 minute, disableClientRequest, storeToken custom-hasher) when trustedAppSchemes registers at least one app scheme, and mounts GET /api/v1/auth/one-time-token/handoff: session required, closed query set {redirect_uri, code_challenge, code_challenge_method}, 302 to a registered scheme only with token=T, 400 VALIDATION_ERROR and no redirect otherwise, 401 signed out. Interception: the plugin's stored key is SHA-256(T:C) for the S256 challenge given at hand-off (bound through an AsyncLocalStorage scope around the in-process vendor generate), and the vendor verify recomputes it from a token sent as T.code_verifier, so an intercepted T is refused with the vendor 400 and consumes nothing. trustedAppSchemes is strict as agreed with the #29736 spec thread (lowercase reverse-domain, no colon suffix, web and script schemes refused at construction and applyConfigPatch); it is declared locally on AuthManagerOptions until #29736 lands, then that line goes on rebase. The PR stays draft until #29736 lands; the maintainer reviews and merges (human floor).",
"tests": "head 766bb724. pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2 src/one-time-token-handoff.test.ts src/auth-route-ledger.conformance.test.ts: Test Files 2 passed, Tests 40 passed (30 new in one-time-token-handoff.test.ts, real AuthManager plus the real registerAuthRoutes mount over the shared in-memory engine). plugin-auth typecheck (src, examples, test layer) under the lock: VERDICT command-exit 0. Ablation, fix committed first, via scripts/ablation-replace.mjs (anchor 1 to 0, blob 71e9b78c to 96549e57, restored to the HEAD blob, git diff HEAD empty): the hasher keyed on T alone (the vendor hashed behaviour, no binding) turned the interception pin and the outside-scope pin red, 28 others green. Before and after measured: on main all three one-time-token routes answer 404; after, generate answers 401 anonymous and 400 Client requests are disabled signed in, verify of a bogus token 400 Invalid token.",
"gates": "node scripts/pm/dispatch-gates.mjs --commands on 766bb724 derived 64 families; 63 run, exit 0 each; pnpm check:dual-build-cjs-loads exit 3 PREREQUISITE NOT MET (reads every package dist, this container built only the plugin-auth closure): NOT MEASURED locally, CI's. check-plugin-teardown-shape --self-test first exit 3 (shallow clone lacked its pinned fixture commit), exit 0 after fetching that one commit. --ran reconciliation exit 0: 64 derived accounted for, 63 run, 1 NOT-MEASURED. check:auth-mount-ledger: 24 mounts accounted for. eslint on the 7 touched source files exit 0; full pnpm lint exit 0 on 766bb724. PR check-runs: not yet concluded when this report was written; the seat reads them.",
"dogfood_battery": "n/a: no exclamation mark in the PR title and the Clause-② line carries the widening arm, not narrowing",
"mcp_calls": "0",
"api_writes": "2: POST /repos/objectstack-ai/objectstack/dispatches (fleet-write relay pr_create, created PR 29766, body read back 15569 of 15569 bytes identical), and this os-dev-report comment through post-stamped.mjs. git push is not a REST write.",
"open_questions": [
{
"question": "A malicious app can start its OWN hand-off (its own challenge, a registered-looking scheme) in a system browser that already holds the user's session; the binding stops interception of someone else's callback, not this. iOS ASWebAuthenticationSession shows a consent sheet unless ephemeral; Android Custom Tabs show nothing. Which mitigation?",
"options": [
"A: refuse a hand-off from a session older than a few minutes (fresh sign-in required); a few lines on the route, no UI, but an existing browser session must sign in again",
"B: an interstitial Continue-to-app page the hand-off renders, which POSTs to mint; UI plus a CSRF-safe POST leg",
"C: accept, relying on the platform consent sheet and the registered-scheme list"
],
"recommendation": "A, because it closes the silent case with no UI; not built here because it changes the shell's flow, which is the mobile card's call too. Stated in the PR's Acceptance notes for the maintainer's review."
}
],
"security_findings": "none",
"out_of_scope_findings": [
"carrier: the mobile shell card · the redeemed session IS the system browser's session (the vendor stores the session token), so signing out in the app signs out the browser and the ADR-0069 gate holding one holds the other; noted in the PR, not filed",
"carrier: none · a redemption writes no sign-in audit event and no session row (the after-hooks key on the sign-in paths); noted in the PR, not filed"
],
"deviations": [
"The dispatch order asks for pnpm lint; os-dev assigns repo-wide lint to CI. Ran both the touched-file eslint and the full pnpm lint, both exit 0.",
"The harness attribution reminder asks for a model-named Co-Authored-By trailer; commits carry the model-free pair AGENTS.md requires. The merge commit of origin/main carries git's default message.",
"No packages/spec edit. The new key is declared on AuthManagerOptions until #29736 lands; on rebase that declaration is deleted and the spec's wins."
],
"body_amendments": [
"After #29736 lands and this branch is rebased onto it: in What changes, replace the sentence that begins Until #29736 lands with one saying the key is read from the spec's AuthConfig.trustedAppSchemes, and cite the new head in Verification."
]
}
Ruling: decision card cmsg_01U5RdnAAmkSQmZbqh6FtqZ8X5ext5gvz9uoXDzxE3L2Ei (thread "Decide mobile sign-in session freshness") · letter A · maintainer tapped 「A · 要求会话新鲜(推荐)」 · recorded 2026-11-03T06:25Z
Tapped at 2026-11-03T06:23Z on the decision card.
What letter A binds (PR #29766, residual risk 1 of its Acceptance notes):
GET /api/v1/auth/one-time-token/handoff refuses unless the caller's session was created within about 5 minutes (a freshness check on the route; no UI).
It also refuses a request carrying Sec-Fetch-Site: cross-site, checked only when that header is present (a browser that sends no such header is not refused for its absence).
B (an interstitial confirm page) is not wanted; C (accept) is not chosen.
Consequence for the mobile shell (objectstack-ai/mobile, the card that designs the shell half): an existing Safari / Custom Tabs session is no longer enough. The shell completes a full sign-in in the system auth session every time before it opens the hand-off; an ephemeral auth session is fine.
For repo:mobile#1 (the filer of this card, os-justin), at 2026-11-03T06:30Z: ruling A on this card (6597001931) changes the shell half of the hand-off.
GET /api/v1/auth/one-time-token/handoff refuses unless the caller's session was created within 5 minutes: 401 UNAUTHENTICATED with a "sign in again" message, the same answer as no session, so the shell needs one branch for both.
It refuses a request that carries Sec-Fetch-Site: cross-site with 400 VALIDATION_ERROR. A request with no such header, or with none, same-origin or same-site, hands off.
For the shell: an existing Safari / Custom Tabs session is no longer enough. Complete a full sign-in in the system auth session every time before opening the hand-off. An ephemeral auth session is fine.
Server side: PR #29766 (built at 4b3e1fd, lands after the spec slice #29804). Please carry this into the mobile card that designs the shell half (from objectstack-ai/mobile#148 item 4 / #176).
Declared:Clause-②: yes (widening) (three auth routes reachable that were 404), Metadata-protocol: none, @objectstack/plugin-auth changeset.
Follow-ups: the mobile shell half (objectstack-ai/mobile, outside this lane): a full sign-in in the system auth session before every hand-off, posted here as 6597073964 for the filer. No card filed in this repo.
pm:dispatched removed in the same stroke as this record.
Activity
objectstack-fleet commentedon Nov 3, 2026
triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U
Path: none
Triage: first-touch audit · p2 ·
domain:server·pm:queue· 2026-11-03T02:55Z⛔ Not a claim.
packages/specby adomain:speccard or PR before the server PR goes ready. The server seat files it at claim, unless the spec lane takes it from here.Generated by Claude Code
objectstack-fleet commentedon Nov 3, 2026
domain:server#2 · os-warren · session_01JQwxbXKRzgnVvjzxaYxLUf
Claim: PM loop round 1 · card thread · 2026-11-03T03:06Z
Session:
session_01JQwxbXKRzgnVvjzxaYxLUf(the seat); the card thread issession_01JQwxbZkE4E6sE72j23AfwxAccount:
os-warrenBranch:
claude/project-thread-p4ozlwWorktree: the card thread's own container checkout
Domain:
domain:serverSeat:
domain:server#2File surface:
packages/plugins/plugin-auth/(plugin list, the app-scheme redirect, the verify binding), its route ledger / envelope / authz rows and tests, one.changeset/29714-*.md; ⛔ nopackages/specpath (that is #29736)Container & model: card thread,
mode:thread,model: TIER_DEFAULT(--tiernames no path mandate),effort: project defaultClause-②: yes
Metadata-protocol: none
Responsibility:
plugin-auth (this lane)|a provider that refuses embedded WebViews blocks mobile sign-in|mobile users signing in with such a provider; reachable today (mobile#176)Thread-read: 6594269680
Premise: holds (card author measured better-auth 1.7.12 ships the plugin; 0 hits for oneTimeToken in plugin-auth on main)
Serial constraints cleared: spec slice filed as #29736 (contract first): this PR stays draft until #29736 lands
Order: project file pm-orders/server/seat2/order-29714.md.
Generated by Claude Code
objectstack-fleet commentedon Nov 3, 2026
domain:server#2 · os-warren · session_01JQwxbZkE4E6sE72j23Afwx
os-dev-report
{ "issue": 29714, "status": "done", "branch": "claude/project-thread-p4ozlw", "pr": "https://github.com/objectstack-ai/objectstack/pull/29766", "session": "session_01JQwxbZkE4E6sE72j23Afwx", "premise_still_valid": true, "summary": "plugin-auth enables better-auth 1.7.12's one-time-token plugin (expiresIn 1 minute, disableClientRequest, storeToken custom-hasher) when trustedAppSchemes registers at least one app scheme, and mounts GET /api/v1/auth/one-time-token/handoff: session required, closed query set {redirect_uri, code_challenge, code_challenge_method}, 302 to a registered scheme only with token=T, 400 VALIDATION_ERROR and no redirect otherwise, 401 signed out. Interception: the plugin's stored key is SHA-256(T:C) for the S256 challenge given at hand-off (bound through an AsyncLocalStorage scope around the in-process vendor generate), and the vendor verify recomputes it from a token sent as T.code_verifier, so an intercepted T is refused with the vendor 400 and consumes nothing. trustedAppSchemes is strict as agreed with the #29736 spec thread (lowercase reverse-domain, no colon suffix, web and script schemes refused at construction and applyConfigPatch); it is declared locally on AuthManagerOptions until #29736 lands, then that line goes on rebase. The PR stays draft until #29736 lands; the maintainer reviews and merges (human floor).", "tests": "head 766bb724. pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2 src/one-time-token-handoff.test.ts src/auth-route-ledger.conformance.test.ts: Test Files 2 passed, Tests 40 passed (30 new in one-time-token-handoff.test.ts, real AuthManager plus the real registerAuthRoutes mount over the shared in-memory engine). plugin-auth typecheck (src, examples, test layer) under the lock: VERDICT command-exit 0. Ablation, fix committed first, via scripts/ablation-replace.mjs (anchor 1 to 0, blob 71e9b78c to 96549e57, restored to the HEAD blob, git diff HEAD empty): the hasher keyed on T alone (the vendor hashed behaviour, no binding) turned the interception pin and the outside-scope pin red, 28 others green. Before and after measured: on main all three one-time-token routes answer 404; after, generate answers 401 anonymous and 400 Client requests are disabled signed in, verify of a bogus token 400 Invalid token.", "gates": "node scripts/pm/dispatch-gates.mjs --commands on 766bb724 derived 64 families; 63 run, exit 0 each; pnpm check:dual-build-cjs-loads exit 3 PREREQUISITE NOT MET (reads every package dist, this container built only the plugin-auth closure): NOT MEASURED locally, CI's. check-plugin-teardown-shape --self-test first exit 3 (shallow clone lacked its pinned fixture commit), exit 0 after fetching that one commit. --ran reconciliation exit 0: 64 derived accounted for, 63 run, 1 NOT-MEASURED. check:auth-mount-ledger: 24 mounts accounted for. eslint on the 7 touched source files exit 0; full pnpm lint exit 0 on 766bb724. PR check-runs: not yet concluded when this report was written; the seat reads them.", "dogfood_battery": "n/a: no exclamation mark in the PR title and the Clause-② line carries the widening arm, not narrowing", "mcp_calls": "0", "api_writes": "2: POST /repos/objectstack-ai/objectstack/dispatches (fleet-write relay pr_create, created PR 29766, body read back 15569 of 15569 bytes identical), and this os-dev-report comment through post-stamped.mjs. git push is not a REST write.", "open_questions": [ { "question": "A malicious app can start its OWN hand-off (its own challenge, a registered-looking scheme) in a system browser that already holds the user's session; the binding stops interception of someone else's callback, not this. iOS ASWebAuthenticationSession shows a consent sheet unless ephemeral; Android Custom Tabs show nothing. Which mitigation?", "options": [ "A: refuse a hand-off from a session older than a few minutes (fresh sign-in required); a few lines on the route, no UI, but an existing browser session must sign in again", "B: an interstitial Continue-to-app page the hand-off renders, which POSTs to mint; UI plus a CSRF-safe POST leg", "C: accept, relying on the platform consent sheet and the registered-scheme list" ], "recommendation": "A, because it closes the silent case with no UI; not built here because it changes the shell's flow, which is the mobile card's call too. Stated in the PR's Acceptance notes for the maintainer's review." } ], "security_findings": "none", "out_of_scope_findings": [ "carrier: the mobile shell card · the redeemed session IS the system browser's session (the vendor stores the session token), so signing out in the app signs out the browser and the ADR-0069 gate holding one holds the other; noted in the PR, not filed", "carrier: none · a redemption writes no sign-in audit event and no session row (the after-hooks key on the sign-in paths); noted in the PR, not filed" ], "deviations": [ "The dispatch order asks for pnpm lint; os-dev assigns repo-wide lint to CI. Ran both the touched-file eslint and the full pnpm lint, both exit 0.", "The harness attribution reminder asks for a model-named Co-Authored-By trailer; commits carry the model-free pair AGENTS.md requires. The merge commit of origin/main carries git's default message.", "No packages/spec edit. The new key is declared on AuthManagerOptions until #29736 lands; on rebase that declaration is deleted and the spec's wins." ], "body_amendments": [ "After #29736 lands and this branch is rebased onto it: in What changes, replace the sentence that begins Until #29736 lands with one saying the key is read from the spec's AuthConfig.trustedAppSchemes, and cite the new head in Verification." ] }objectstack-fleet commentedon Nov 3, 2026
domain:server#2 · os-warren · session_01JQwxbXKRzgnVvjzxaYxLUf
Ruling: decision card cmsg_01U5RdnAAmkSQmZbqh6FtqZ8X5ext5gvz9uoXDzxE3L2Ei (thread "Decide mobile sign-in session freshness") · letter A · maintainer tapped 「A · 要求会话新鲜(推荐)」 · recorded 2026-11-03T06:25Z
Tapped at 2026-11-03T06:23Z on the decision card.
What letter A binds (PR #29766, residual risk 1 of its Acceptance notes):
GET /api/v1/auth/one-time-token/handoffrefuses unless the caller's session was created within about 5 minutes (a freshness check on the route; no UI).Sec-Fetch-Site: cross-site, checked only when that header is present (a browser that sends no such header is not refused for its absence).Clause-②unchanged by the ruling (yes (widening), the route itself; the freshness rule narrows only the route this PR adds).Generated by Claude Code
objectstack-fleet commentedon Nov 3, 2026
domain:server#2 · os-warren · session_01JQwxbXKRzgnVvjzxaYxLUf
For repo:mobile#1 (the filer of this card, os-justin), at 2026-11-03T06:30Z: ruling A on this card (6597001931) changes the shell half of the hand-off.
GET /api/v1/auth/one-time-token/handoffrefuses unless the caller's session was created within 5 minutes:401 UNAUTHENTICATEDwith a "sign in again" message, the same answer as no session, so the shell needs one branch for both.Sec-Fetch-Site: cross-sitewith400 VALIDATION_ERROR. A request with no such header, or withnone,same-originorsame-site, hands off.Server side: PR #29766 (built at 4b3e1fd, lands after the spec slice #29804). Please carry this into the mobile card that designs the shell half (from objectstack-ai/mobile#148 item 4 / #176).
Generated by Claude Code
objectstack-fleet commentedon Nov 3, 2026
domain:server#2 · os-warren · session_01JQwxbXKRzgnVvjzxaYxLUf
Landing record · #29714 · 2026-11-03T08:23Z
2b15e302fa45af2dcb122005c3752aec2377c023; head01e2f33e; this card closed byFixes.TIER_DEFAULT(declaration face; the spec half 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 / PR feat(spec): declare the mobile sign-in hand-off surface (one-time-token hand-off and verify, registered app schemes) #29804 had its contract review) → ruling A (6597001931) built at4b3e1fd5→ rebased onto feat(spec): declare the mobile sign-in hand-off surface (one-time-token hand-off and verify, registered app schemes) #29804 at01e2f33e(specTrustedAppSchemeSchemaimported, route paths fromAuthEndpointPaths) → PR body amended by the seat → seat ACCEPT 6598400823 →pr_ready+automerge_enableby the seat.Clause-②: yes (widening)(three auth routes reachable that were 404),Metadata-protocol: none,@objectstack/plugin-authchangeset.pm:dispatchedremoved in the same stroke as this record.Generated by Claude Code