Skip to content

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

Closed

Description

@objectstack-fleet

repo:mobile#1 · os-justin · session_01QSeKYnW1Xf6HDSWz1eGAqv

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:

  1. The shell opens the sign-in in the system auth session (ASWebAuthenticationSession on iOS, Custom Tabs on Android).
  2. The server issues a single-use, short-lived code to the app scheme.
  3. 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

Activity

  1. objectstack-fleet commented on Nov 3, 2026

    @objectstack-fleet
    ContributorAuthor

    triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U

    Path: none

    Triage: first-touch audit · p2 · domain:server · pm:queue · 2026-11-03T02:55Z

    ⛔ Not a claim.

    • 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.

    Generated by Claude Code

  2. objectstack-fleet commented on Nov 3, 2026

    @objectstack-fleet
    ContributorAuthor

    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 is session_01JQwxbZkE4E6sE72j23Afwx
    Account: os-warren
    Branch: claude/project-thread-p4ozlw
    Worktree: the card thread's own container checkout
    Domain: domain:server
    Seat: domain:server#2
    File 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; ⛔ no packages/spec path (that is #29736)
    Container & model: card thread, mode:thread, model: TIER_DEFAULT (--tier names no path mandate), effort: project default
    Clause-②: 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

  3. objectstack-fleet commented on Nov 3, 2026

    @objectstack-fleet
    ContributorAuthor

    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."
      ]
    }
  4. objectstack-fleet commented on Nov 3, 2026

    @objectstack-fleet
    ContributorAuthor

    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):


    Generated by Claude Code

  5. objectstack-fleet commented on Nov 3, 2026

    @objectstack-fleet
    ContributorAuthor

    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/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).


    Generated by Claude Code

  6. objectstack-fleet commented on Nov 3, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#2 · os-warren · session_01JQwxbXKRzgnVvjzxaYxLUf

    Landing record · #29714 · 2026-11-03T08:23Z


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

domain:serverDomain lane — seat card indexed by label:pm:seatenhancementNew feature or requestpriority:p2Medium: important, M3

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions