From e3ab4e021166f7d7040ca62b9ccc7bc9674e7019 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 03:17:48 +0000 Subject: [PATCH 1/5] feat(plugin-auth): mobile sign-in hand-off over better-auth's one-time-token plugin (WIP) Claude-Session: https://claude.ai/code/session_01JQwxbZkE4E6sE72j23Afwx Co-authored-by: Claude --- .../src/auth-manager-plugin-list.ts | 27 ++ .../plugins/plugin-auth/src/auth-manager.ts | 26 ++ .../plugins/plugin-auth/src/auth-plugin.ts | 42 +++ .../src/auth-route-ledger.conformance.test.ts | 13 + .../plugin-auth/src/auth-route-ledger.ts | 9 + .../src/one-time-token-handoff.test.ts | 312 ++++++++++++++++++ .../plugin-auth/src/one-time-token-handoff.ts | 306 +++++++++++++++++ 7 files changed, 735 insertions(+) create mode 100644 packages/plugins/plugin-auth/src/one-time-token-handoff.test.ts create mode 100644 packages/plugins/plugin-auth/src/one-time-token-handoff.ts diff --git a/packages/plugins/plugin-auth/src/auth-manager-plugin-list.ts b/packages/plugins/plugin-auth/src/auth-manager-plugin-list.ts index a48a439316a..eaec573b712 100644 --- a/packages/plugins/plugin-auth/src/auth-manager-plugin-list.ts +++ b/packages/plugins/plugin-auth/src/auth-manager-plugin-list.ts @@ -14,6 +14,8 @@ * `phoneNumber`, `magicLink`, the generic OAuth providers, the OIDC provider * with its `jwt` plugin, `sso`, `scim`, device authorization and the custom * session — each with its own failure tier, as the method's docblock states. + * (Since the move one plugin joined the list: the one-time token, ahead of + * the custom session, for the mobile sign-in hand-off.) * The `organization` factory's body was moved out by cut 15; its arrow, which * hands that body its host, moves here with the rest. * @@ -79,6 +81,11 @@ import { buildTwoFactorPluginSchema, } from './auth-schema-config.js'; import { isPlaceholderEmail } from './placeholder-email.js'; +import { + ONE_TIME_TOKEN_EXPIRES_IN_MINUTES, + oneTimeTokenStoreHasher, + registeredAppSchemesOf, +} from './one-time-token-handoff.js'; import { userInfoLocaleClaim } from './oidc-userinfo-locale-claim.js'; import { buildTurnstileSignUpPlugin, reportHalfConfiguredTurnstile, resolveTurnstileKeys } from './turnstile-sign-up.js'; @@ -802,6 +809,26 @@ export async function buildPluginList(this: PluginListHost): Promise { }); } + // One-time token — the mobile sign-in hand-off (maintainer ruling, option + // B: better-auth's own plugin, no hand-written exchange endpoint). Mounted + // only when the deployment registers at least one app scheme, because the + // hand-off has nowhere to send a token otherwise. Generation is refused + // over HTTP (`disableClientRequest`): the only minter is the ObjectStack + // hand-off route, which binds each token to the shell's PKCE challenge + // through `storeToken`'s custom hasher, so an intercepted callback cannot + // be redeemed. Lifetime and the binding are argued in + // `one-time-token-handoff.ts`. + if (registeredAppSchemesOf(this.config).length > 0) { + await this.addOptionalPlugin(plugins, 'oneTimeToken', async () => { + const { oneTimeToken } = await import('better-auth/plugins/one-time-token'); + return oneTimeToken({ + expiresIn: ONE_TIME_TOKEN_EXPIRES_IN_MINUTES, + disableClientRequest: true, + storeToken: { type: 'custom-hasher', hash: oneTimeTokenStoreHasher }, + }); + }); + } + // customSession() — augments the session payload with the canonical // `positions: string[]` array (ADR-0068 D1/D2, renamed ADR-0090 D3) and its // derived `isPlatformAdmin` alias. diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index e1aca1b975e..a22137fd65c 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -209,6 +209,7 @@ import { createAuthInstance, type AuthInstanceHost } from './auth-manager-auth-i // with it. import { buildPluginList, type PluginListHost } from './auth-manager-plugin-list.js'; import { resolveTurnstileKeys } from './turnstile-sign-up.js'; +import { assertTrustedAppSchemes, registeredAppSchemesOf } from './one-time-token-handoff.js'; /** * Detect WebContainer (StackBlitz) environment. @@ -665,6 +666,13 @@ export function readInvitationPlacementIntent( } export interface AuthManagerOptions extends Partial { + /** + * The app schemes the mobile sign-in hand-off may redirect a one-time token + * to (`one-time-token-handoff.ts`). Declared here only until the spec's + * `AuthConfig` carries the key; then this line goes and the spec's wins. + */ + trustedAppSchemes?: string[]; + /** * Better-Auth instance (for advanced use cases) * If not provided, one will be created from config @@ -1603,6 +1611,12 @@ export class AuthManager { // appear after boot. assertScimAdminCoherence(config.plugins); + // The mobile sign-in hand-off's registered app schemes — same + // boot-loudly rationale: an entry that is not a reverse-domain app scheme + // is refused here, never dropped (`one-time-token-handoff.ts`). + // `applyConfigPatch` re-runs it on the merged result. + assertTrustedAppSchemes(config.trustedAppSchemes); + // WebContainer (StackBlitz) compatibility — install a synchronous // AsyncLocalStorage polyfill for better-auth's request-state global // BEFORE better-auth ever instantiates its own. See the helper for the @@ -1782,6 +1796,15 @@ export class AuthManager { * fine but could not actually sign (#3585) — the plugin's endpoints are * mounted, they just cannot mint a token. */ + /** + * The app schemes the mobile sign-in hand-off may redirect a token to, + * normalised — the same reading the plugin list mounts the one-time-token + * plugin from, so the route and the plugin cannot disagree. + */ + getRegisteredAppSchemes(): string[] { + return registeredAppSchemesOf(this.config); + } + getDegradedAuthFeatures(): Array<{ feature: string; error: string }> { return Array.from(this.degradedFeatures, ([feature, error]) => ({ feature, error })); } @@ -2059,6 +2082,9 @@ export class AuthManager { if ('plugins' in patch) { assertScimAdminCoherence(next.plugins); } + if ('trustedAppSchemes' in patch) { + assertTrustedAppSchemes(next.trustedAppSchemes); + } this.config = next; this.deploymentDeclaredVerificationOff = deploymentDeclaredVerificationOff; diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index 3964b585fb4..3be3180556c 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -81,6 +81,7 @@ import { runSetInitialPassword } from './set-initial-password.js'; import { runRegisterSsoProviderFromForm, runRegisterSamlProviderFromForm, runRequestDomainVerification, runVerifyDomain, type AuthRequestHandler } from './register-sso-provider.js'; import { runResendVerificationEmail } from './send-verification-email.js'; import { runSysSessionRevoke, sysSessionRevokeDepsFor } from './sys-session-revoke.js'; +import { runOneTimeTokenHandoff } from './one-time-token-handoff.js'; import { readJsonRequestBody } from './request-json-body.js'; import { seedCreatedOrganizationCurrency, @@ -4013,6 +4014,47 @@ export class AuthPlugin implements Plugin { } }); + // ──────────────────────────────────────────────────────────────────── + // The mobile sign-in hand-off: a session signed in in the SYSTEM browser + // is handed to a REGISTERED app scheme as a single-use, one-minute token + // the app redeems at better-auth's own `/one-time-token/verify`, bound to + // the shell's PKCE challenge so an intercepted callback cannot be + // redeemed. Mounted unconditionally: with no registered scheme every + // target is refused with a 400 that says how to register one. Session + // required; the reasons for every refusal live in + // `one-time-token-handoff.ts`. Ledgered in auth-route-ledger.ts. + rawApp.get(`${basePath}/one-time-token/handoff`, async (c: any) => { + try { + const manager = this.authManager!; + const answer = await runOneTimeTokenHandoff( + { + registeredSchemes: manager.getRegisteredAppSchemes(), + hasSession: async (headers) => { + const api: any = await manager.getApi(); + return Boolean(await api.getSession({ headers })); + }, + generate: async (headers) => { + const api: any = await manager.getApi(); + return api.generateOneTimeToken({ headers }); + }, + }, + { queries: (c.req.queries() ?? {}) as Record, headers: c.req.raw.headers }, + ); + if (answer.status === 302) { + return new Response(null, { status: 302, headers: answer.headers }); + } + return c.json(answer.body, answer.status as any, answer.headers); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + ctx.logger.error('[AuthPlugin] one-time-token/handoff failed', err); + return c.json( + { success: false, error: { code: 'INTERNAL_ERROR', message: 'The sign-in hand-off is unavailable.' } }, + 500, + { 'Cache-Control': 'no-store' }, + ); + } + }); + // ──────────────────────────────────────────────────────────────────── // ADR-0069 P3 — register a SAML 2.0 IdP. Mirrors the OIDC bridge above: // the metadata `register_saml_provider` action posts FLAT fields; the shared diff --git a/packages/plugins/plugin-auth/src/auth-route-ledger.conformance.test.ts b/packages/plugins/plugin-auth/src/auth-route-ledger.conformance.test.ts index b77fdfe21f8..66a2b44a464 100644 --- a/packages/plugins/plugin-auth/src/auth-route-ledger.conformance.test.ts +++ b/packages/plugins/plugin-auth/src/auth-route-ledger.conformance.test.ts @@ -43,6 +43,9 @@ const LEDGERED_PLUGIN_CONFIG = { deviceAuthorization: true, }; +/** Registered app schemes: mounts the mobile sign-in hand-off's `one-time-token` plugin. */ +const LEDGERED_APP_SCHEMES = ['com.example.ledger']; + /** * `buildPluginList` reads env vars that can flip plugins on top of the config * above (`OS_SSO_ENABLED`, `OS_SCIM_ENABLED`, …). A developer with any of them @@ -76,6 +79,10 @@ beforeAll(async () => { secret: 'route-ledger-conformance-secret-32-chars', baseUrl: 'http://localhost:9', plugins: LEDGERED_PLUGIN_CONFIG, + // The one-time-token plugin is switched by a registered app scheme, not + // by a `plugins` flag: one is registered so the maximal surface carries + // its two vendor routes. + trustedAppSchemes: LEDGERED_APP_SCHEMES, } as never); // No dataEngine: createDatabaseConfig() falls back to better-auth's in-memory // adapter. Route registration does not touch the database — only request @@ -215,6 +222,12 @@ describe('auth route ledger hygiene', () => { expect(own).toEqual([ 'GET /api/v1/auth/bootstrap-status', 'GET /api/v1/auth/config', + // On this pin's own terms: `auth-plugin.ts` mounts it itself (a + // `rawApp.get` on the `${basePath}/one-time-token/handoff` + // template, ahead of the catch-all), and the `live.has(route)` loop + // below holds it to the same proof — the vendor plugin publishes only + // `/one-time-token/generate` and `/one-time-token/verify`. + 'GET /api/v1/auth/one-time-token/handoff', // [#26020] On this pin's own terms: `auth-plugin.ts` mounts it itself (a // `rawApp.get` on the `${basePath}/organization/invitation-preview` // template, ahead of the catch-all), and the `live.has(route)` loop below diff --git a/packages/plugins/plugin-auth/src/auth-route-ledger.ts b/packages/plugins/plugin-auth/src/auth-route-ledger.ts index 2f9e866521c..a6fe99f434d 100644 --- a/packages/plugins/plugin-auth/src/auth-route-ledger.ts +++ b/packages/plugins/plugin-auth/src/auth-route-ledger.ts @@ -283,6 +283,13 @@ export const AUTH_ROUTE_LEDGER: readonly AuthRouteLedgerEntry[] = [ { route: 'POST /api/v1/auth/organization/update', family: 'organization', source: 'better-auth', disposition: 'sdk', client: 'organizations.update', requires: 'organization' }, { route: 'POST /api/v1/auth/organization/update-member-role', family: 'organization', source: 'better-auth', disposition: 'sdk', client: 'organizations.updateMemberRole', requires: 'organization' }, { route: 'POST /api/v1/auth/organization/update-team', family: 'organization', source: 'better-auth', disposition: 'sdk', client: 'organizations.teams.update', requires: 'organization' }, + // The mobile sign-in hand-off (maintainer ruling, option B): better-auth's + // `one-time-token` plugin, mounted only when `trustedAppSchemes` registers + // an app scheme, plus the ObjectStack hand-off that is its only minter. + // `one-time-token-handoff.ts` carries the interception argument. + { 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' }, + { 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' }, + { 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 — the mobile app shell opens it in the system browser after a sign-in there; session required. Closed query set { redirect_uri, code_challenge, code_challenge_method }: 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. The mount is unconditional; with no registered scheme every target is refused' }, { route: 'POST /api/v1/auth/two-factor/disable', family: 'two-factor', source: 'better-auth', disposition: 'sdk', client: 'auth.twoFactor.disable', requires: 'twoFactor' }, { route: 'POST /api/v1/auth/two-factor/enable', family: 'two-factor', source: 'better-auth', disposition: 'sdk', client: 'auth.twoFactor.enable', requires: 'twoFactor' }, { route: 'POST /api/v1/auth/two-factor/generate-backup-codes', family: 'two-factor', source: 'better-auth', disposition: 'sdk', client: 'auth.twoFactor.generateBackupCodes', requires: 'twoFactor' }, @@ -344,6 +351,7 @@ export const BETTER_AUTH_MOUNTED_SURFACE: readonly string[] = [ 'GET /api/v1/auth/oauth2/public-client', 'GET /api/v1/auth/oauth2/userinfo', 'GET /api/v1/auth/ok', + 'GET /api/v1/auth/one-time-token/generate', 'GET /api/v1/auth/organization/get-active-member', 'GET /api/v1/auth/organization/get-active-member-role', 'GET /api/v1/auth/organization/get-full-organization', @@ -389,6 +397,7 @@ export const BETTER_AUTH_MOUNTED_SURFACE: readonly string[] = [ 'POST /api/v1/auth/get-session', 'POST /api/v1/auth/link-social', 'POST /api/v1/auth/oauth2/authorize', + 'POST /api/v1/auth/one-time-token/verify', // `private_key_jwt` client key rotation. Added to `@better-auth/oauth-provider` // after 1.7.3 and published here since the 1.7.12 lift (#25096). The handler // refuses before it reads anything unless the plugin is given diff --git a/packages/plugins/plugin-auth/src/one-time-token-handoff.test.ts b/packages/plugins/plugin-auth/src/one-time-token-handoff.test.ts new file mode 100644 index 00000000000..2f2258d290e --- /dev/null +++ b/packages/plugins/plugin-auth/src/one-time-token-handoff.test.ts @@ -0,0 +1,312 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// The mobile sign-in hand-off (`one-time-token-handoff.ts`): a session signed +// in in the system browser is handed to a REGISTERED app scheme as a +// single-use token bound to the shell's PKCE challenge, and redeemed at +// better-auth's own `/one-time-token/verify`. +// +// ## Why the server here is the real one +// +// The interception claim is a claim about what better-auth's plugin STORES and +// what it LOOKS UP: that a callback another app intercepts cannot be redeemed, +// and that trying does not burn the real holder's token. A double of the +// plugin would certify a lookup this file invented. So every end-to-end case +// runs the real `AuthManager` (better-auth 1.7.12 and its `one-time-token` +// plugin) over the in-memory engine the package's other suites share, and the +// hand-off through the REAL raw-app registration (`registerAuthRoutes`), as +// `sys-session-revoke.test.ts` does. + +import { describe, it, expect, vi, beforeEach, afterEach, afterAll } from 'vitest'; +import { Hono } from 'hono'; +import type { PluginContext } from '@objectstack/core'; +import { AuthManager } from './auth-manager'; +import { AuthPlugin } from './auth-plugin'; +import { createMemoryEngine } from './impersonation-bearer-rotation.test'; +import { inviteForAudienceGate } from './audience-gate-test-support'; +import { memoizeScrypt } from './__tests__/scrypt-memo.testkit'; +import { + NEVER_APP_SCHEMES, + ONE_TIME_TOKEN_EXPIRES_IN_MINUTES, + ONE_TIME_TOKEN_GENERATE_PATH, + ONE_TIME_TOKEN_HANDOFF_PATH, + ONE_TIME_TOKEN_VERIFY_PATH, + appSchemeRefusal, + assertTrustedAppSchemes, + oneTimeTokenStoreHasher, + runOneTimeTokenHandoff, + s256Challenge, +} from './one-time-token-handoff'; + +const SECRET = 'test-secret-at-least-32-chars-long!!'; +const PASSWORD = 'S3cure!Passw0rd-one-time-token'; +const ORIGIN = 'http://localhost:3000'; +const BASE = '/api/v1/auth'; +const ALICE_EMAIL = 'alice@one-time-token.example.com'; +const APP_SCHEME = 'com.example.mobile'; +const CALLBACK = `${APP_SCHEME}://auth/callback?state=shell-state`; +/** An RFC 7636 code_verifier, as the shell would make it. */ +const VERIFIER = 'Shell-made_code.verifier~0123456789abcdefghijklmnop'; + +const mockCtx = (): PluginContext => + ({ + registerService: vi.fn(), + getService: vi.fn((name: string) => (name === 'manifest' ? { register: vi.fn() } : undefined)), + getServices: vi.fn(() => new Map()), + hook: vi.fn(), + trigger: vi.fn(), + logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() }, + getKernel: vi.fn(), + }) as any; + +const makeManager = (engine: any, trustedAppSchemes: string[] | undefined = [APP_SCHEME]) => + new AuthManager({ secret: SECRET, baseUrl: ORIGIN, dataEngine: engine, trustedAppSchemes } as any); + +const direct = (manager: AuthManager, path: string, init: RequestInit = {}) => + manager.handleRequest(new Request(`${ORIGIN}${BASE}${path}`, init)); + +const postJson = (manager: AuthManager, path: string, body: unknown) => + direct(manager, path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); + +const cookieFrom = (response: Response): string => + (response.headers.getSetCookie?.() ?? [response.headers.get('set-cookie') ?? '']) + .map((c) => c.split(';')[0]) + .filter(Boolean) + .join('; '); + +const userRows = (engine: any) => (engine.tables.get('sys_user') ?? []) as any[]; +const verificationRows = (engine: any) => (engine.tables.get('sys_verification') ?? []) as any[]; + +/** The one sign-up every case starts from, made once per file (one scrypt derivation). */ +let signedUpTables: Promise> | undefined; +const signedUpOnce = (): Promise> => + (signedUpTables ??= (async () => { + const template = createMemoryEngine(); + const manager = makeManager(template); + await inviteForAudienceGate(manager, ALICE_EMAIL); + const res = await postJson(manager, '/sign-up/email', { email: ALICE_EMAIL, password: PASSWORD, name: 'Alice' }); + expect(res.status, `sign-up: ${await res.clone().text()}`).toBe(200); + return template.tables as Map; + })()); + +const arrange = async (trustedAppSchemes?: string[]) => { + const engine = createMemoryEngine(); + for (const [name, rows] of await signedUpOnce()) engine.tables.set(name, structuredClone(rows)); + const manager = makeManager(engine, trustedAppSchemes); + + // The REAL route registration — raw mounts ahead of the catch-all. + const app = new Hono(); + const ctx = mockCtx(); + const plugin = new AuthPlugin({ secret: SECRET }); + await plugin.init(ctx); + (plugin as any).authManager = manager; + (plugin as any).registerAuthRoutes({ getRawApp: () => app, getPort: () => 0 }, ctx); + + const signIn = async (): Promise => { + const res = await postJson(manager, '/sign-in/email', { email: ALICE_EMAIL, password: PASSWORD }); + expect(res.status, `sign-in: ${await res.clone().text()}`).toBe(200); + return cookieFrom(res); + }; + + /** GET the hand-off through the Hono app, as the system browser does. */ + const handoff = (cookie: string | undefined, query: Record) => + app.request(`${ORIGIN}${BASE}${ONE_TIME_TOKEN_HANDOFF_PATH}?${new URLSearchParams(query)}`, { + headers: cookie ? { cookie } : {}, + }); + + /** Hand off and return the token the app callback received. */ + const handOff = async (cookie: string, verifier = VERIFIER): Promise => { + const res = await handoff(cookie, { redirect_uri: CALLBACK, code_challenge: s256Challenge(verifier), code_challenge_method: 'S256' }); + expect(res.status, `hand-off: ${await res.clone().text()}`).toBe(302); + const token = new URL(res.headers.get('location')!).searchParams.get('token'); + expect(token).toMatch(/^[A-Za-z0-9_-]{32}$/); + return token!; + }; + + const redeem = (token: string) => postJson(manager, ONE_TIME_TOKEN_VERIFY_PATH, { token }); + + return { engine, manager, signIn, handoff, handOff, redeem }; +}; + +beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); +}); +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +const scrypt = memoizeScrypt(); +afterAll(scrypt.restore); + +describe('one-time-token hand-off — the redeem is bound to the shell that started it', () => { + it('hands the system-browser session to the registered app scheme, and the holder of the verifier redeems it once', async () => { + const { engine, signIn, handoff, redeem } = await arrange(); + const cookie = await signIn(); + + const res = await handoff(cookie, { redirect_uri: CALLBACK, code_challenge: s256Challenge(VERIFIER) }); + expect(res.status).toBe(302); + expect(res.headers.get('cache-control')).toBe('no-store'); + expect(res.headers.get('referrer-policy')).toBe('no-referrer'); + const location = new URL(res.headers.get('location')!); + expect(location.protocol).toBe(`${APP_SCHEME}:`); + expect(location.searchParams.get('state')).toBe('shell-state'); + const token = location.searchParams.get('token')!; + + const redeemed = await redeem(`${token}.${VERIFIER}`); + expect(redeemed.status).toBe(200); + const body = (await redeemed.json()) as any; + expect(body.user.id).toBe(userRows(engine).find((r) => r.email === ALICE_EMAIL).id); + expect(cookieFrom(redeemed)).toContain('session_token='); + + const replay = await redeem(`${token}.${VERIFIER}`); + expect(replay.status).toBe(400); + expect(((await replay.json()) as any).message).toBe('Invalid token'); + }); + + it('an intercepted callback cannot be redeemed, and trying does not burn the real holder\'s token', async () => { + const { signIn, handOff, redeem } = await arrange(); + const token = await handOff(await signIn()); + + // The interceptor holds the callback URL, so the token, and nothing else. + for (const attempt of [token, `${token}.`, `${token}.${'A'.repeat(43)}`, `${token}.${s256Challenge(VERIFIER)}`]) { + const res = await redeem(attempt); + expect(res.status, attempt).toBe(400); + expect(((await res.json()) as any).message).toBe('Invalid token'); + expect(cookieFrom(res)).not.toContain('session_token='); + } + + // The shell that started the hand-off still redeems it. + expect((await redeem(`${token}.${VERIFIER}`)).status).toBe(200); + }); + + it('stores the token hashed and bound to the challenge: neither the token nor the challenge is in the row', async () => { + const { engine, signIn, handOff } = await arrange(); + const before = verificationRows(engine).length; + const token = await handOff(await signIn()); + const rows = verificationRows(engine).slice(before); + expect(rows).toHaveLength(1); + const identifier = String(rows[0].identifier); + expect(identifier.startsWith('one-time-token:')).toBe(true); + expect(identifier).not.toContain(token); + expect(identifier).not.toContain(s256Challenge(VERIFIER)); + // The same key the redemption computes from `T.verifier`. + expect(identifier).toBe(`one-time-token:${await oneTimeTokenStoreHasher(`${token}.${VERIFIER}`)}`); + }); + + it(`expires after ${ONE_TIME_TOKEN_EXPIRES_IN_MINUTES} minute`, async () => { + const { signIn, handOff, redeem } = await arrange(); + const token = await handOff(await signIn()); + vi.useFakeTimers({ toFake: ['Date'], now: Date.now() + ONE_TIME_TOKEN_EXPIRES_IN_MINUTES * 60_000 + 1_000 }); + const res = await redeem(`${token}.${VERIFIER}`); + expect(res.status).toBe(400); + }); + + it('refuses a client request to the vendor generate route: the hand-off is the only minter', async () => { + const { manager, signIn } = await arrange(); + const cookie = await signIn(); + const signedIn = await direct(manager, ONE_TIME_TOKEN_GENERATE_PATH, { headers: { cookie } }); + expect(signedIn.status).toBe(400); + expect(((await signedIn.json()) as any).message).toBe('Client requests are disabled'); + expect((await direct(manager, ONE_TIME_TOKEN_GENERATE_PATH)).status).toBe(401); + }); + + it('a token minted server-side outside the hand-off is unredeemable (fails closed)', async () => { + const { manager, signIn, redeem } = await arrange(); + const cookie = await signIn(); + const api: any = await manager.getApi(); + const { token } = await api.generateOneTimeToken({ headers: new Headers({ cookie }) }); + expect((await redeem(token)).status).toBe(400); + expect((await redeem(`${token}.${VERIFIER}`)).status).toBe(400); + }); +}); + +describe('one-time-token hand-off — the redirect goes to a registered app scheme only', () => { + it('refuses an unregistered scheme with a 400 and no redirect', async () => { + const { signIn, handoff } = await arrange(); + const cookie = await signIn(); + for (const redirect_uri of ['com.attacker.app://cb', 'https://attacker.example/cb', 'javascript:alert(1)']) { + const res = await handoff(cookie, { redirect_uri, code_challenge: s256Challenge(VERIFIER) }); + expect(res.status, redirect_uri).toBe(400); + expect(res.headers.get('location')).toBeNull(); + expect(((await res.json()) as any).error.code).toBe('VALIDATION_ERROR'); + } + }); + + it('matches the registered scheme case-insensitively', async () => { + const { signIn, handoff } = await arrange(); + const res = await handoff(await signIn(), { + redirect_uri: 'COM.Example.Mobile://auth/callback', + code_challenge: s256Challenge(VERIFIER), + }); + expect(res.status).toBe(302); + }); + + it('with no registered scheme the plugin is not mounted and the hand-off refuses every target', async () => { + const { manager, signIn, handoff } = await arrange([]); + const cookie = await signIn(); + expect((await postJson(manager, ONE_TIME_TOKEN_VERIFY_PATH, { token: 'x' })).status).toBe(404); + const res = await handoff(cookie, { redirect_uri: CALLBACK, code_challenge: s256Challenge(VERIFIER) }); + expect(res.status).toBe(400); + expect(res.headers.get('location')).toBeNull(); + }); + + it('refuses a signed-out caller with 401, after the request itself is valid', async () => { + const { handoff } = await arrange(); + const res = await handoff(undefined, { redirect_uri: CALLBACK, code_challenge: s256Challenge(VERIFIER) }); + expect(res.status).toBe(401); + expect(((await res.json()) as any).error.code).toBe('UNAUTHENTICATED'); + }); +}); + +describe('one-time-token hand-off — the request is validated before anything is minted', () => { + const generate = vi.fn(async () => ({ token: 'never' })); + const deps = { registeredSchemes: [APP_SCHEME], hasSession: async () => true, generate }; + const ask = (queries: Record) => runOneTimeTokenHandoff(deps, { queries, headers: new Headers() }); + const challenge = s256Challenge(VERIFIER); + + it.each([ + ['an unknown parameter', { redirect_uri: [CALLBACK], code_challenge: [challenge], scope: ['x'] }], + ['a repeated redirect_uri', { redirect_uri: [CALLBACK, CALLBACK], code_challenge: [challenge] }], + ['no redirect_uri', { code_challenge: [challenge] }], + ['a relative redirect_uri', { redirect_uri: ['/cb'], code_challenge: [challenge] }], + ['no code_challenge', { redirect_uri: [CALLBACK] }], + ['a malformed code_challenge', { redirect_uri: [CALLBACK], code_challenge: ['short'] }], + ['a plain code_challenge_method', { redirect_uri: [CALLBACK], code_challenge: [challenge], code_challenge_method: ['plain'] }], + ])('refuses %s with 400 VALIDATION_ERROR and mints nothing', async (_label, queries) => { + generate.mockClear(); + const answer = await ask(queries as Record); + expect(answer.status).toBe(400); + expect((answer.body as any).error.code).toBe('VALIDATION_ERROR'); + expect(answer.headers.Location).toBeUndefined(); + expect(generate).not.toHaveBeenCalled(); + }); +}); + +describe('trustedAppSchemes — refused at construction, never dropped', () => { + it.each([ + ['https'], ['HTTPS'], ['javascript'], ['data'], + ['Com.Example.App'], ['com.example.app://'], ['com.example.app:'], ['myapp'], [''], [42], + ])('refuses %j', (entry) => { + expect(appSchemeRefusal(entry)).toBeDefined(); + expect(() => assertTrustedAppSchemes(['com.example.ok', entry])).toThrow(/invalid trustedAppSchemes/); + expect(() => makeManager(createMemoryEngine(), ['com.example.ok', entry as string])).toThrow(/invalid trustedAppSchemes/); + }); + + it('accepts reverse-domain private-use schemes', () => { + for (const entry of ['com.example.app', 'io.objectstack.mobile', 'com.example.app-beta']) { + expect(appSchemeRefusal(entry)).toBeUndefined(); + } + expect(() => assertTrustedAppSchemes(undefined)).not.toThrow(); + }); + + it('every web or script scheme is refused even in reverse-domain-looking company', () => { + for (const scheme of NEVER_APP_SCHEMES) expect(appSchemeRefusal(scheme)).toBeDefined(); + }); + + it('applyConfigPatch refuses a bad list and keeps the standing one', () => { + const manager = makeManager(createMemoryEngine()); + expect(() => manager.applyConfigPatch({ trustedAppSchemes: ['https'] } as any)).toThrow(/invalid trustedAppSchemes/); + expect(manager.getRegisteredAppSchemes()).toEqual([APP_SCHEME]); + }); +}); diff --git a/packages/plugins/plugin-auth/src/one-time-token-handoff.ts b/packages/plugins/plugin-auth/src/one-time-token-handoff.ts new file mode 100644 index 00000000000..71e9b78c0e4 --- /dev/null +++ b/packages/plugins/plugin-auth/src/one-time-token-handoff.ts @@ -0,0 +1,306 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The mobile sign-in hand-off: a sign-in completed in the SYSTEM browser is + * handed back to the app's WebView as a single-use, short-lived token + * (maintainer ruling, option B: reuse better-auth's `one-time-token` plugin, + * no hand-written exchange endpoint). + * + * ## Why it exists + * + * Some identity providers (Google among them) refuse to sign a user in inside + * an embedded WebView. The mobile shell therefore opens the sign-in in the + * platform's system auth session (`ASWebAuthenticationSession`, Custom Tabs), + * where the session cookie lands in the system browser, not in the WebView the + * Console runs in. This module carries that session across, once. + * + * ## The flow, and which half is whose + * + * 1. The shell makes a random `code_verifier` (RFC 7636 §4.1) and keeps it. + * 2. The shell opens, in the system auth session, + * `GET /api/v1/auth/one-time-token/handoff?redirect_uri=SCHEME://PATH&code_challenge=C&code_challenge_method=S256` + * with `C = BASE64URL(SHA-256(code_verifier))`, after (or as the end of) a + * sign-in there. + * 3. This route checks that `redirect_uri` names a REGISTERED app scheme, + * mints a token from the signed-in session through the vendor plugin's own + * generate endpoint (called server-side; the plugin refuses it over HTTP), + * and answers `302` to `redirect_uri` with `token=T` added. + * 4. The shell (or the Console it hands both values to) redeems at the vendor + * route `POST /api/v1/auth/one-time-token/verify` with + * `{ "token": "T.code_verifier" }` and receives that session (and its + * cookie, on the WebView's origin). + * + * ## How an intercepted callback cannot be redeemed + * + * The vendor plugin is not bound to the attempt that started the sign-in: by + * itself, whoever holds `T` redeems it. On a phone another app can register + * the same custom scheme and receive the callback. So the token's storage key + * is bound to the challenge, PKCE-style, through the plugin's own published + * `storeToken: { type: 'custom-hasher' }` option ({@link oneTimeTokenStoreHasher}): + * + * - at generation the plugin stores the session under + * `SHA-256(T ":" C)`, where `C` is the challenge this route was given; + * - at redemption the plugin looks the session up under the hash of what the + * caller sent, which this hasher computes as `SHA-256(T ":" S256(verifier))` + * from `"T.verifier"`. + * + * The two keys meet only when the caller holds the verifier whose challenge + * started the hand-off. An interceptor holds `T` alone: `T`, or `T` with any + * guessed verifier, hashes to a key no row has, so the plugin answers its own + * `400 Invalid token` — and, because the lookup is by that wrong key, it + * consumes nothing: the real holder still redeems afterwards. The verifier + * never leaves the device (only its hash travels, in step 2). + * + * Generation happens only inside this route ({@link withHandoffChallenge}): + * the plugin's generate endpoint refuses every HTTP request + * (`disableClientRequest`), and a server-side call made outside this route's + * scope stores a key no redemption can reach, so it fails closed. + * + * ## The registered app-scheme list + * + * `redirect_uri` must name a scheme the deployment registered in + * `AuthConfig.trustedAppSchemes` (matched case-insensitively); any other + * target is refused with `400 VALIDATION_ERROR` and NO redirect, so the route + * is never an open redirect. The list itself is refused at construction when + * an entry is not a reverse-domain private-use scheme (RFC 8252 §7.1) or is a + * web or script scheme ({@link NEVER_APP_SCHEMES}): a hand-off to `https:` + * would put the token in a URL any site could receive. + * + * ## Query parameters — a closed set (AGENTS.md route rule 5) + * + * `redirect_uri`, `code_challenge`, `code_challenge_method`, each at most + * once; any other name answers the shared recognition rule's located 400. + */ + +import { AsyncLocalStorage } from 'node:async_hooks'; +import { createHash, randomBytes } from 'node:crypto'; +import { repeatedQueryParamMessage, unknownQueryParamRefusalMessage } from '@objectstack/rest'; + +/** The hand-off route under the auth base path (an ObjectStack mount). */ +export const ONE_TIME_TOKEN_HANDOFF_PATH = '/one-time-token/handoff'; + +/** The vendor plugin's redemption route under the auth base path. */ +export const ONE_TIME_TOKEN_VERIFY_PATH = '/one-time-token/verify'; + +/** The vendor plugin's generate route; refused over HTTP (`disableClientRequest`). */ +export const ONE_TIME_TOKEN_GENERATE_PATH = '/one-time-token/generate'; + +/** + * Token lifetime, in MINUTES (the plugin's unit). One minute: the token + * travels from the system browser to the app by a redirect the operating + * system delivers at once, and is redeemed by the app's very next request, so + * a whole minute is generous even on a slow device — and every second beyond + * that is only time for a stolen token to be tried. The vendor default is 3. + */ +export const ONE_TIME_TOKEN_EXPIRES_IN_MINUTES = 1; + +/** The route's CLOSED query-parameter set. */ +export const ONE_TIME_TOKEN_HANDOFF_QUERY_PARAMS: readonly string[] = [ + 'redirect_uri', + 'code_challenge', + 'code_challenge_method', +]; + +/** + * Schemes that are never an app scheme, whatever the registered list says: the + * browser itself navigates, renders or executes them, so a token handed to one + * would leave the device's app boundary. + */ +export const NEVER_APP_SCHEMES: readonly string[] = [ + 'http', 'https', 'javascript', 'data', 'file', 'blob', 'about', 'vbscript', 'ws', 'wss', +]; + +/** RFC 7636 §4.1 `code_verifier`: 43–128 unreserved characters. */ +const CODE_VERIFIER_SYNTAX = /^[A-Za-z0-9\-._~]{43,128}$/; + +/** RFC 7636 §4.2 S256 `code_challenge`: BASE64URL of a SHA-256 digest, no padding. */ +const S256_CHALLENGE_SYNTAX = /^[A-Za-z0-9\-_]{43}$/; + +/** RFC 7636 §4.2 S256: `BASE64URL(SHA-256(ASCII(code_verifier)))`. */ +export function s256Challenge(codeVerifier: string): string { + return createHash('sha256').update(codeVerifier, 'ascii').digest('base64url'); +} + +/** The storage key a token is kept under, bound to the challenge of the attempt that minted it. */ +function bindingKey(token: string, challenge: string): string { + return createHash('sha256').update(`${token}:${challenge}`, 'utf8').digest('base64url'); +} + +/** A key no stored row can carry: a redemption that is not well formed finds nothing. */ +function unreachableKey(): string { + return `unredeemable:${randomBytes(18).toString('base64url')}`; +} + +/** The challenge of the hand-off in progress, visible to the hasher while the plugin generates. */ +const handoffChallenge = new AsyncLocalStorage(); + +/** + * Run `generate` with `challenge` bound, so the token the plugin mints inside + * it is stored under {@link bindingKey}`(token, challenge)`. + */ +export function withHandoffChallenge(challenge: string, generate: () => Promise): Promise { + return handoffChallenge.run(challenge, generate); +} + +/** + * The plugin's `storeToken.hash`. Inside {@link withHandoffChallenge} it is + * hashing a token the plugin just generated; everywhere else it is hashing + * what a redeemer sent, `"T.code_verifier"`. The generated token's alphabet + * (`a-z 0-9 A-Z - _`) has no `.`, so the first `.` is the separator. + */ +export async function oneTimeTokenStoreHasher(input: string): Promise { + const challenge = handoffChallenge.getStore(); + if (challenge !== undefined) return bindingKey(input, challenge); + const dot = input.indexOf('.'); + if (dot <= 0) return unreachableKey(); + const token = input.slice(0, dot); + const codeVerifier = input.slice(dot + 1); + if (!CODE_VERIFIER_SYNTAX.test(codeVerifier)) return unreachableKey(); + return bindingKey(token, s256Challenge(codeVerifier)); +} + +/** + * A registered app scheme, as RFC 8252 §7.1 requires of a private-use scheme: + * lowercase RFC 3986 scheme syntax containing a dot (reverse-domain, e.g. + * `com.example.app`), with no `:` suffix. The reverse-domain rule is also the + * first interception defence: a scheme named for a domain the app's + * publisher controls is one another app has no business claiming. + */ +const APP_SCHEME_SYNTAX = /^[a-z][a-z0-9+.-]*$/; + +/** + * Why `entry` cannot be a registered app scheme, or `undefined` when it can. + * The same rules the spec's `AuthConfig.trustedAppSchemes` refuses at parse; + * repeated here because the auth manager also receives plain objects. + */ +export function appSchemeRefusal(entry: unknown): string | undefined { + if (typeof entry !== 'string') return 'is not a string'; + if (NEVER_APP_SCHEMES.includes(entry.toLowerCase().replace(/:.*$/, ''))) { + return 'is a web or script scheme the browser itself handles, never an app scheme'; + } + if (!APP_SCHEME_SYNTAX.test(entry) || !entry.includes('.')) { + return 'is not a lowercase reverse-domain scheme with no ":" suffix (RFC 8252 §7.1), e.g. "com.example.app"'; + } + return undefined; +} + +/** + * Refuse a configured scheme list with any entry {@link appSchemeRefusal} + * rejects — loudly, at construction, naming every bad entry. An entry is + * never dropped: a deployment that misspelt its scheme would otherwise boot + * with a hand-off that refuses its own app. + */ +export function assertTrustedAppSchemes(configured: unknown): void { + if (configured === undefined) return; + if (!Array.isArray(configured)) { + throw new Error('[auth] trustedAppSchemes must be an array of app schemes, e.g. ["com.example.app"].'); + } + const problems = configured + .map((entry) => [entry, appSchemeRefusal(entry)] as const) + .filter(([, why]) => why !== undefined) + .map(([entry, why]) => `${JSON.stringify(entry)} ${why}`); + if (problems.length > 0) { + throw new Error( + `[auth] invalid trustedAppSchemes: ${problems.join('; ')}. ` + + 'Each entry is the custom URL scheme of a native app the mobile sign-in hand-off may redirect a token to.', + ); + } +} + +/** + * The deployment's registered app schemes, from `AuthConfig.trustedAppSchemes` + * (declared by the spec beside `trustedOrigins`), after + * {@link assertTrustedAppSchemes}. Empty means the hand-off has nowhere to + * send a token, and the plugin is not mounted. + */ +export function registeredAppSchemesOf(config: { trustedAppSchemes?: readonly string[] } | undefined): string[] { + const configured = config?.trustedAppSchemes; + assertTrustedAppSchemes(configured); + return [...new Set(configured ?? [])]; +} + +export interface HandoffAnswer { + status: 302 | 400 | 401 | 500; + body?: unknown; + headers: Record; +} + +export interface HandoffDeps { + /** The registered app schemes ({@link registeredAppSchemesOf}). */ + registeredSchemes: readonly string[]; + /** Whether `headers` carry a live session. */ + hasSession(headers: Headers): Promise; + /** The vendor plugin's generate endpoint, called server-side with the caller's headers. */ + generate(headers: Headers): Promise<{ token: string }>; +} + +export interface HandoffRequest { + /** Every query parameter as the transport parsed it, each name with all its values. */ + queries: Record; + headers: Headers; +} + +/** Every answer of this route is uncacheable and sends no referrer: a token is in flight. */ +const NO_STORE = { 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer' } as const; + +function refusal(status: 400 | 401, code: string, message: string): HandoffAnswer { + return { status, body: { success: false, error: { code, message } }, headers: { ...NO_STORE } }; +} + +/** The single value of `name`, `undefined` when absent; a repeat is a refusal message. */ +function single(queries: Record, name: string): { value?: string; refusal?: string } { + const values = queries[name] ?? []; + if (values.length > 1) return { refusal: repeatedQueryParamMessage(name, values.length) }; + return { value: values[0] }; +} + +/** `GET /api/v1/auth/one-time-token/handoff` — see the module docblock. */ +export async function runOneTimeTokenHandoff(deps: HandoffDeps, request: HandoffRequest): Promise { + const unknown = unknownQueryParamRefusalMessage(request.queries, ONE_TIME_TOKEN_HANDOFF_QUERY_PARAMS); + if (unknown !== undefined) return refusal(400, 'VALIDATION_ERROR', unknown); + + const redirect = single(request.queries, 'redirect_uri'); + if (redirect.refusal) return refusal(400, 'VALIDATION_ERROR', redirect.refusal); + if (!redirect.value) { + return refusal(400, 'VALIDATION_ERROR', 'The "redirect_uri" query parameter is required: the app callback the token is handed to.'); + } + let target: URL; + try { + target = new URL(redirect.value); + } catch { + return refusal(400, 'VALIDATION_ERROR', 'The "redirect_uri" query parameter is not an absolute URI.'); + } + const scheme = target.protocol.replace(/:$/, '').toLowerCase(); + if (!deps.registeredSchemes.includes(scheme)) { + return refusal( + 400, + 'VALIDATION_ERROR', + `The "redirect_uri" scheme "${scheme}" is not a registered app scheme, so no token is handed to it. ` + + 'A deployment registers the app schemes it hands sign-ins to in its auth configuration.', + ); + } + + const challenge = single(request.queries, 'code_challenge'); + if (challenge.refusal) return refusal(400, 'VALIDATION_ERROR', challenge.refusal); + if (!challenge.value || !S256_CHALLENGE_SYNTAX.test(challenge.value)) { + return refusal( + 400, + 'VALIDATION_ERROR', + 'The "code_challenge" query parameter is required: BASE64URL(SHA-256(code_verifier)), 43 characters (RFC 7636 S256). ' + + 'The redemption must present that code_verifier, so a callback another app intercepts cannot be redeemed.', + ); + } + const method = single(request.queries, 'code_challenge_method'); + if (method.refusal) return refusal(400, 'VALIDATION_ERROR', method.refusal); + if (method.value !== undefined && method.value !== 'S256') { + return refusal(400, 'VALIDATION_ERROR', 'The "code_challenge_method" query parameter accepts only "S256".'); + } + + if (!(await deps.hasSession(request.headers))) { + return refusal(401, 'UNAUTHENTICATED', 'Sign in first: the hand-off carries the signed-in session of this browser to the app.'); + } + + const { token } = await withHandoffChallenge(challenge.value, () => deps.generate(request.headers)); + target.searchParams.set('token', token); + return { status: 302, headers: { ...NO_STORE, Location: target.toString() } }; +} From 72960374141fffea9c15b060f9c73b08fcebbee1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 03:21:15 +0000 Subject: [PATCH 2/5] chore(changeset): mobile sign-in hand-off Claude-Session: https://claude.ai/code/session_01JQwxbZkE4E6sE72j23Afwx Co-authored-by: Claude --- .changeset/29714-mobile-sign-in-handoff.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .changeset/29714-mobile-sign-in-handoff.md diff --git a/.changeset/29714-mobile-sign-in-handoff.md b/.changeset/29714-mobile-sign-in-handoff.md new file mode 100644 index 00000000000..4ee3bd74458 --- /dev/null +++ b/.changeset/29714-mobile-sign-in-handoff.md @@ -0,0 +1,16 @@ +--- +'@objectstack/plugin-auth': patch +--- + +feat(plugin-auth): the mobile sign-in hand-off — a sign-in completed in the system browser is handed to a registered app scheme as a single-use token, over better-auth's own `one-time-token` plugin + +Clause-②: yes (widening) +Metadata-protocol: none + +Some identity providers (Google among them) refuse to sign a user in inside an embedded WebView, so a mobile shell signs in in the platform's system auth session, where the session lands in the system browser rather than in the WebView the Console runs in. A deployment that lists its app's custom URL scheme in `trustedAppSchemes` now gets a way to carry that session across, once: + +- **`GET /api/v1/auth/one-time-token/handoff`** (session required). Query: `redirect_uri` (an app callback), `code_challenge` (RFC 7636 S256: `BASE64URL(SHA-256(code_verifier))`, 43 characters) and optionally `code_challenge_method=S256`; no other parameter is accepted. It answers `302` to `redirect_uri` with a `token` query parameter added, uncacheable. A `redirect_uri` whose scheme is not registered answers `400 VALIDATION_ERROR` with no redirect, so the route is never an open redirect; a missing or malformed challenge also answers `400`, and a signed-out caller `401 UNAUTHENTICATED`. +- **`POST /api/v1/auth/one-time-token/verify`** (better-auth's route, no session needed). Body: `{ "token": "TOKEN.code_verifier" }`. It answers better-auth's `{ session, user }` with the session cookie, once, within one minute. +- **An intercepted callback cannot be redeemed.** Another app on the device can register the same scheme and receive the callback, so the token is stored under a hash of the token and the challenge, and redemption recomputes it from the verifier the shell kept. A token presented without that verifier answers better-auth's `400 Invalid token` and consumes nothing, so the shell that started the hand-off still redeems it. +- **`GET /api/v1/auth/one-time-token/generate`** is published by the plugin and refuses every client request (`400` with a session, `401` without); the hand-off is the only place a token is minted. +- **`trustedAppSchemes`** entries must be lowercase reverse-domain private-use schemes with no `:` suffix (RFC 8252 §7.1, e.g. `com.example.app`); `http`, `https`, `javascript`, `data`, `file`, `blob`, `about`, `vbscript`, `ws` and `wss` are refused. A bad entry fails the auth manager's construction (and `applyConfigPatch`), naming the entry. With no scheme registered, the plugin is not mounted and the hand-off refuses every target. From 34edeb96aec86893f6e975223138baf0a4e855d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 04:08:24 +0000 Subject: [PATCH 3/5] test(plugin-auth): account for the one-time-token plugin in both auth-manager plugin inventories Claude-Session: https://claude.ai/code/session_01JQwxbZkE4E6sE72j23Afwx Co-authored-by: Claude --- .../src/better-auth-schema-parity.test.ts | 14 ++++++++++++++ .../src/managed-extension-fields.test.ts | 14 ++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/packages/plugins/plugin-auth/src/better-auth-schema-parity.test.ts b/packages/plugins/plugin-auth/src/better-auth-schema-parity.test.ts index 77eb7b7e326..abb93e18d8c 100644 --- a/packages/plugins/plugin-auth/src/better-auth-schema-parity.test.ts +++ b/packages/plugins/plugin-auth/src/better-auth-schema-parity.test.ts @@ -102,6 +102,8 @@ import { magicLink } from 'better-auth/plugins/magic-link'; import { phoneNumber } from 'better-auth/plugins/phone-number'; import { jwt } from 'better-auth/plugins/jwt'; import { deviceAuthorization } from 'better-auth/plugins/device-authorization'; +import { oneTimeToken } from 'better-auth/plugins/one-time-token'; +import { ONE_TIME_TOKEN_EXPIRES_IN_MINUTES, oneTimeTokenStoreHasher } from './one-time-token-handoff.js'; import { sso } from '@better-auth/sso'; import { scim } from '@better-auth/scim'; import { @@ -244,6 +246,18 @@ const AUTH_MANAGER_PLUGINS: Record unknown } | { skip deviceAuthorization: { construct: () => deviceAuthorization({ schema: buildDeviceAuthorizationPluginSchema() }), }, + // The options the plugin list passes. The plugin stores its tokens in the + // core `verification` model and declares no model or column of its own; + // it is constructed anyway, so a version bump that gives it one is + // compared the day it lands. + oneTimeToken: { + construct: () => + oneTimeToken({ + expiresIn: ONE_TIME_TOKEN_EXPIRES_IN_MINUTES, + disableClientRequest: true, + storeToken: { type: 'custom-hasher', hash: oneTimeTokenStoreHasher }, + }), + }, customSession: { construct: () => customSession(async ({ user, session }: { user: unknown; session: unknown }) => ({ diff --git a/packages/plugins/plugin-auth/src/managed-extension-fields.test.ts b/packages/plugins/plugin-auth/src/managed-extension-fields.test.ts index e68bc461b5b..2d515d895bc 100644 --- a/packages/plugins/plugin-auth/src/managed-extension-fields.test.ts +++ b/packages/plugins/plugin-auth/src/managed-extension-fields.test.ts @@ -107,6 +107,8 @@ import { admin } from 'better-auth/plugins/admin'; import { bearer } from 'better-auth/plugins/bearer'; import { customSession } from 'better-auth/plugins/custom-session'; import { deviceAuthorization } from 'better-auth/plugins/device-authorization'; +import { oneTimeToken } from 'better-auth/plugins/one-time-token'; +import { ONE_TIME_TOKEN_EXPIRES_IN_MINUTES, oneTimeTokenStoreHasher } from './one-time-token-handoff.js'; import { genericOAuth } from 'better-auth/plugins/generic-oauth'; import { haveIBeenPwned } from 'better-auth/plugins/haveibeenpwned'; import { magicLink } from 'better-auth/plugins/magic-link'; @@ -507,6 +509,18 @@ const AUTH_MANAGER_PLUGINS: Record unknown } | { skip genericOAuth: { construct: () => genericOAuth({ config: [] }) }, jwt: { construct: () => jwt() }, deviceAuthorization: { construct: () => deviceAuthorization() }, + // The options the plugin list passes. The plugin stores its tokens in the + // core `verification` model and declares no model or column of its own; + // it is constructed anyway, so a version bump that gives it one is + // compared the day it lands. + oneTimeToken: { + construct: () => + oneTimeToken({ + expiresIn: ONE_TIME_TOKEN_EXPIRES_IN_MINUTES, + disableClientRequest: true, + storeToken: { type: 'custom-hasher', hash: oneTimeTokenStoreHasher }, + }), + }, customSession: { construct: () => customSession(async ({ user, session }: { user: unknown; session: unknown }) => ({ From 4b3e1fd51edb26b6d9ef11548490db31679e92c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 06:28:13 +0000 Subject: [PATCH 4/5] feat(plugin-auth): the hand-off carries only a session signed in within five minutes, and refuses a cross-site navigation Claude-Session: https://claude.ai/code/session_01JQwxbZkE4E6sE72j23Afwx Co-authored-by: Claude --- .../plugins/plugin-auth/src/auth-plugin.ts | 11 ++-- .../src/one-time-token-handoff.test.ts | 51 ++++++++++++++- .../plugin-auth/src/one-time-token-handoff.ts | 64 ++++++++++++++++++- 3 files changed, 116 insertions(+), 10 deletions(-) diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index 3be3180556c..de18624bd66 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -4020,8 +4020,9 @@ export class AuthPlugin implements Plugin { // the app redeems at better-auth's own `/one-time-token/verify`, bound to // the shell's PKCE challenge so an intercepted callback cannot be // redeemed. Mounted unconditionally: with no registered scheme every - // target is refused with a 400 that says how to register one. Session - // required; the reasons for every refusal live in + // target is refused with a 400 that says how to register one. A session + // signed in within the last five minutes is required, and a cross-site + // navigation is refused (ruling A); the reasons for every refusal live in // `one-time-token-handoff.ts`. Ledgered in auth-route-ledger.ts. rawApp.get(`${basePath}/one-time-token/handoff`, async (c: any) => { try { @@ -4029,9 +4030,11 @@ export class AuthPlugin implements Plugin { const answer = await runOneTimeTokenHandoff( { registeredSchemes: manager.getRegisteredAppSchemes(), - hasSession: async (headers) => { + sessionCreatedAt: async (headers) => { const api: any = await manager.getApi(); - return Boolean(await api.getSession({ headers })); + const live = await api.getSession({ headers }); + const createdAt = live?.session?.createdAt; + return createdAt ? new Date(createdAt) : null; }, generate: async (headers) => { const api: any = await manager.getApi(); diff --git a/packages/plugins/plugin-auth/src/one-time-token-handoff.test.ts b/packages/plugins/plugin-auth/src/one-time-token-handoff.test.ts index 2f2258d290e..618183ac1d1 100644 --- a/packages/plugins/plugin-auth/src/one-time-token-handoff.test.ts +++ b/packages/plugins/plugin-auth/src/one-time-token-handoff.test.ts @@ -27,6 +27,7 @@ import { memoizeScrypt } from './__tests__/scrypt-memo.testkit'; import { NEVER_APP_SCHEMES, ONE_TIME_TOKEN_EXPIRES_IN_MINUTES, + ONE_TIME_TOKEN_HANDOFF_SESSION_MAX_AGE_SECONDS, ONE_TIME_TOKEN_GENERATE_PATH, ONE_TIME_TOKEN_HANDOFF_PATH, ONE_TIME_TOKEN_VERIFY_PATH, @@ -108,9 +109,9 @@ const arrange = async (trustedAppSchemes?: string[]) => { }; /** GET the hand-off through the Hono app, as the system browser does. */ - const handoff = (cookie: string | undefined, query: Record) => + const handoff = (cookie: string | undefined, query: Record, extra: Record = {}) => app.request(`${ORIGIN}${BASE}${ONE_TIME_TOKEN_HANDOFF_PATH}?${new URLSearchParams(query)}`, { - headers: cookie ? { cookie } : {}, + headers: { ...(cookie ? { cookie } : {}), ...extra }, }); /** Hand off and return the token the app callback received. */ @@ -259,9 +260,53 @@ describe('one-time-token hand-off — the redirect goes to a registered app sche }); }); +describe('one-time-token hand-off — only a fresh sign-in, opened by the app itself (ruling A)', () => { + const query = { redirect_uri: CALLBACK, code_challenge: s256Challenge(VERIFIER) }; + + it(`refuses a session older than ${ONE_TIME_TOKEN_HANDOFF_SESSION_MAX_AGE_SECONDS} seconds with 401 and mints nothing`, async () => { + const { engine, signIn, handoff } = await arrange(); + const cookie = await signIn(); + const before = verificationRows(engine).length; + vi.useFakeTimers({ toFake: ['Date'], now: Date.now() + ONE_TIME_TOKEN_HANDOFF_SESSION_MAX_AGE_SECONDS * 1000 + 1_000 }); + const res = await handoff(cookie, query); + expect(res.status).toBe(401); + expect(((await res.json()) as any).error.code).toBe('UNAUTHENTICATED'); + expect(res.headers.get('location')).toBeNull(); + expect(verificationRows(engine).length).toBe(before); + }); + + it('still hands off a session inside the window', async () => { + const { signIn, handoff } = await arrange(); + const cookie = await signIn(); + vi.useFakeTimers({ toFake: ['Date'], now: Date.now() + ONE_TIME_TOKEN_HANDOFF_SESSION_MAX_AGE_SECONDS * 1000 - 5_000 }); + expect((await handoff(cookie, query)).status).toBe(302); + }); + + it('refuses a cross-site navigation with 400 and mints nothing', async () => { + const { engine, signIn, handoff } = await arrange(); + const cookie = await signIn(); + const before = verificationRows(engine).length; + const res = await handoff(cookie, query, { 'sec-fetch-site': 'cross-site' }); + expect(res.status).toBe(400); + expect(((await res.json()) as any).error.code).toBe('VALIDATION_ERROR'); + expect(res.headers.get('location')).toBeNull(); + expect(verificationRows(engine).length).toBe(before); + }); + + it.each([['none'], ['same-origin'], ['same-site']])('hands off when Sec-Fetch-Site is %s', async (site) => { + const { signIn, handoff } = await arrange(); + expect((await handoff(await signIn(), query, { 'sec-fetch-site': site })).status).toBe(302); + }); + + it('does not refuse a request that carries no Sec-Fetch-Site at all', async () => { + const { signIn, handoff } = await arrange(); + expect((await handoff(await signIn(), query)).status).toBe(302); + }); +}); + describe('one-time-token hand-off — the request is validated before anything is minted', () => { const generate = vi.fn(async () => ({ token: 'never' })); - const deps = { registeredSchemes: [APP_SCHEME], hasSession: async () => true, generate }; + const deps = { registeredSchemes: [APP_SCHEME], sessionCreatedAt: async () => new Date(), generate }; const ask = (queries: Record) => runOneTimeTokenHandoff(deps, { queries, headers: new Headers() }); const challenge = s256Challenge(VERIFIER); diff --git a/packages/plugins/plugin-auth/src/one-time-token-handoff.ts b/packages/plugins/plugin-auth/src/one-time-token-handoff.ts index 71e9b78c0e4..0417170fcc6 100644 --- a/packages/plugins/plugin-auth/src/one-time-token-handoff.ts +++ b/packages/plugins/plugin-auth/src/one-time-token-handoff.ts @@ -51,6 +51,24 @@ * consumes nothing: the real holder still redeems afterwards. The verifier * never leaves the device (only its hash travels, in step 2). * + * ## Who can open it (maintainer ruling A, card comment 6597001931) + * + * The binding stops a callback being redeemed by an app that intercepts it. + * It does not stop an app or a web page from OPENING the hand-off itself, + * with its own challenge, in a browser that already holds the user's session. + * Two refusals close that: + * + * - **Freshness.** The session must have been created within + * {@link ONE_TIME_TOKEN_HANDOFF_SESSION_MAX_AGE_SECONDS}; an older one + * answers `401 UNAUTHENTICATED` (the remedy is a sign-in, the same remedy + * as no session). So the shell completes a full sign-in in the system auth + * session before every hand-off (an ephemeral session is fine); an existing + * Safari or Custom Tabs session is not enough. + * - **Cross-site navigation.** A request carrying `Sec-Fetch-Site: + * cross-site` answers `400 VALIDATION_ERROR` before anything else is read: + * a malformed request context, not a missing sign-in. The header is judged + * only when present. + * * Generation happens only inside this route ({@link withHandoffChallenge}): * the plugin's generate endpoint refuses every HTTP request * (`disableClientRequest`), and a server-side call made outside this route's @@ -94,6 +112,17 @@ export const ONE_TIME_TOKEN_GENERATE_PATH = '/one-time-token/generate'; */ export const ONE_TIME_TOKEN_EXPIRES_IN_MINUTES = 1; +/** + * How old the caller's session may be, in SECONDS, for the hand-off to carry + * it (maintainer ruling A on the residual risk this PR raised, card comment + * 6597001931). The hand-off hands over a session the shell has just watched + * the user create in the system auth session; a session older than this was + * made for something else, and a page or app that opens the hand-off against + * it would carry it off silently. Five minutes covers a sign-in with a slow + * second factor. + */ +export const ONE_TIME_TOKEN_HANDOFF_SESSION_MAX_AGE_SECONDS = 5 * 60; + /** The route's CLOSED query-parameter set. */ export const ONE_TIME_TOKEN_HANDOFF_QUERY_PARAMS: readonly string[] = [ 'redirect_uri', @@ -228,8 +257,14 @@ export interface HandoffAnswer { export interface HandoffDeps { /** The registered app schemes ({@link registeredAppSchemesOf}). */ registeredSchemes: readonly string[]; - /** Whether `headers` carry a live session. */ - hasSession(headers: Headers): Promise; + /** + * When the live session `headers` carry was created, or `null` when they + * carry none: the vendor session's `session.createdAt` (`sys_session.created_at`), + * which a refresh or a bearer replay never moves (those move `updatedAt`). + */ + sessionCreatedAt(headers: Headers): Promise; + /** The clock, for the freshness window; `Date.now` by default. */ + now?: () => number; /** The vendor plugin's generate endpoint, called server-side with the caller's headers. */ generate(headers: Headers): Promise<{ token: string }>; } @@ -256,6 +291,19 @@ function single(queries: Record, name: string): { val /** `GET /api/v1/auth/one-time-token/handoff` — see the module docblock. */ export async function runOneTimeTokenHandoff(deps: HandoffDeps, request: HandoffRequest): Promise { + // A navigation another site started (a link, a form, a script) arrives with + // `Sec-Fetch-Site: cross-site` and the session cookie, since `SameSite=Lax` + // sends it on a top-level GET. The shell opens the hand-off itself, which a + // browser reports as `none`. Judged only when the header is present: a + // browser that sends none is not refused for its absence (ruling A). + if (request.headers.get('sec-fetch-site')?.trim().toLowerCase() === 'cross-site') { + return refusal( + 400, + 'VALIDATION_ERROR', + 'The sign-in hand-off cannot be opened from another site: the app opens it in its own system auth session.', + ); + } + const unknown = unknownQueryParamRefusalMessage(request.queries, ONE_TIME_TOKEN_HANDOFF_QUERY_PARAMS); if (unknown !== undefined) return refusal(400, 'VALIDATION_ERROR', unknown); @@ -296,9 +344,19 @@ export async function runOneTimeTokenHandoff(deps: HandoffDeps, request: Handoff return refusal(400, 'VALIDATION_ERROR', 'The "code_challenge_method" query parameter accepts only "S256".'); } - if (!(await deps.hasSession(request.headers))) { + const createdAt = await deps.sessionCreatedAt(request.headers); + if (!createdAt) { return refusal(401, 'UNAUTHENTICATED', 'Sign in first: the hand-off carries the signed-in session of this browser to the app.'); } + const ageMs = (deps.now ?? Date.now)() - createdAt.getTime(); + if (!(ageMs <= ONE_TIME_TOKEN_HANDOFF_SESSION_MAX_AGE_SECONDS * 1000)) { + return refusal( + 401, + 'UNAUTHENTICATED', + `Sign in again: the hand-off carries only a session signed in within the last ${ONE_TIME_TOKEN_HANDOFF_SESSION_MAX_AGE_SECONDS / 60} minutes, ` + + 'so the app completes a sign-in in its system auth session before every hand-off.', + ); + } const { token } = await withHandoffChallenge(challenge.value, () => deps.generate(request.headers)); target.searchParams.set('token', token); From 01e2f33e629b4823858b7ef28b8afe7c2692964c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 07:02:30 +0000 Subject: [PATCH 5/5] refactor(plugin-auth): the hand-off reads trustedAppSchemes and its route 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 --- .changeset/29714-mobile-sign-in-handoff.md | 3 +- .../plugins/plugin-auth/src/auth-manager.ts | 7 ---- .../src/one-time-token-handoff.test.ts | 2 +- .../plugin-auth/src/one-time-token-handoff.ts | 32 +++++++++---------- 4 files changed, 19 insertions(+), 25 deletions(-) diff --git a/.changeset/29714-mobile-sign-in-handoff.md b/.changeset/29714-mobile-sign-in-handoff.md index 4ee3bd74458..d7508df575e 100644 --- a/.changeset/29714-mobile-sign-in-handoff.md +++ b/.changeset/29714-mobile-sign-in-handoff.md @@ -10,7 +10,8 @@ Metadata-protocol: none Some identity providers (Google among them) refuse to sign a user in inside an embedded WebView, so a mobile shell signs in in the platform's system auth session, where the session lands in the system browser rather than in the WebView the Console runs in. A deployment that lists its app's custom URL scheme in `trustedAppSchemes` now gets a way to carry that session across, once: - **`GET /api/v1/auth/one-time-token/handoff`** (session required). Query: `redirect_uri` (an app callback), `code_challenge` (RFC 7636 S256: `BASE64URL(SHA-256(code_verifier))`, 43 characters) and optionally `code_challenge_method=S256`; no other parameter is accepted. It answers `302` to `redirect_uri` with a `token` query parameter added, uncacheable. A `redirect_uri` whose scheme is not registered answers `400 VALIDATION_ERROR` with no redirect, so the route is never an open redirect; a missing or malformed challenge also answers `400`, and a signed-out caller `401 UNAUTHENTICATED`. +- **The hand-off carries only a fresh sign-in.** A session signed in more than five minutes ago answers `401 UNAUTHENTICATED` (sign in again), so an app cannot silently carry away a session the browser has held for days; and a navigation the browser reports as `Sec-Fetch-Site: cross-site` answers `400 VALIDATION_ERROR` (the header is checked only when the browser sends it). - **`POST /api/v1/auth/one-time-token/verify`** (better-auth's route, no session needed). Body: `{ "token": "TOKEN.code_verifier" }`. It answers better-auth's `{ session, user }` with the session cookie, once, within one minute. - **An intercepted callback cannot be redeemed.** Another app on the device can register the same scheme and receive the callback, so the token is stored under a hash of the token and the challenge, and redemption recomputes it from the verifier the shell kept. A token presented without that verifier answers better-auth's `400 Invalid token` and consumes nothing, so the shell that started the hand-off still redeems it. - **`GET /api/v1/auth/one-time-token/generate`** is published by the plugin and refuses every client request (`400` with a session, `401` without); the hand-off is the only place a token is minted. -- **`trustedAppSchemes`** entries must be lowercase reverse-domain private-use schemes with no `:` suffix (RFC 8252 §7.1, e.g. `com.example.app`); `http`, `https`, `javascript`, `data`, `file`, `blob`, `about`, `vbscript`, `ws` and `wss` are refused. A bad entry fails the auth manager's construction (and `applyConfigPatch`), naming the entry. With no scheme registered, the plugin is not mounted and the hand-off refuses every target. +- **`trustedAppSchemes`** (the spec's `AuthConfig.trustedAppSchemes`) entries must be lowercase reverse-domain private-use schemes with no `:` suffix (RFC 8252 §7.1, e.g. `com.example.app`, no empty or trailing label); `http`, `https`, `javascript`, `data`, `file`, `blob`, `about`, `vbscript`, `ws` and `wss` are refused. A bad entry fails the auth manager's construction (and `applyConfigPatch`), naming the entry. With no scheme registered, the plugin is not mounted and the hand-off refuses every target. diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index a22137fd65c..9f1b2a62235 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -666,13 +666,6 @@ export function readInvitationPlacementIntent( } export interface AuthManagerOptions extends Partial { - /** - * The app schemes the mobile sign-in hand-off may redirect a one-time token - * to (`one-time-token-handoff.ts`). Declared here only until the spec's - * `AuthConfig` carries the key; then this line goes and the spec's wins. - */ - trustedAppSchemes?: string[]; - /** * Better-Auth instance (for advanced use cases) * If not provided, one will be created from config diff --git a/packages/plugins/plugin-auth/src/one-time-token-handoff.test.ts b/packages/plugins/plugin-auth/src/one-time-token-handoff.test.ts index 618183ac1d1..e6c6fbd4b48 100644 --- a/packages/plugins/plugin-auth/src/one-time-token-handoff.test.ts +++ b/packages/plugins/plugin-auth/src/one-time-token-handoff.test.ts @@ -331,7 +331,7 @@ describe('one-time-token hand-off — the request is validated before anything i describe('trustedAppSchemes — refused at construction, never dropped', () => { it.each([ ['https'], ['HTTPS'], ['javascript'], ['data'], - ['Com.Example.App'], ['com.example.app://'], ['com.example.app:'], ['myapp'], [''], [42], + ['Com.Example.App'], ['com.example.app://'], ['com.example.app:'], ['myapp'], ['com..app'], ['com.example.'], [''], [42], ])('refuses %j', (entry) => { expect(appSchemeRefusal(entry)).toBeDefined(); expect(() => assertTrustedAppSchemes(['com.example.ok', entry])).toThrow(/invalid trustedAppSchemes/); diff --git a/packages/plugins/plugin-auth/src/one-time-token-handoff.ts b/packages/plugins/plugin-auth/src/one-time-token-handoff.ts index 0417170fcc6..ad5f8e97e78 100644 --- a/packages/plugins/plugin-auth/src/one-time-token-handoff.ts +++ b/packages/plugins/plugin-auth/src/one-time-token-handoff.ts @@ -93,12 +93,14 @@ import { AsyncLocalStorage } from 'node:async_hooks'; import { createHash, randomBytes } from 'node:crypto'; import { repeatedQueryParamMessage, unknownQueryParamRefusalMessage } from '@objectstack/rest'; +import { AuthEndpointPaths } from '@objectstack/spec/api'; +import { TrustedAppSchemeSchema } from '@objectstack/spec/system'; -/** The hand-off route under the auth base path (an ObjectStack mount). */ -export const ONE_TIME_TOKEN_HANDOFF_PATH = '/one-time-token/handoff'; +/** The hand-off route under the auth base path (an ObjectStack mount), as the spec names it. */ +export const ONE_TIME_TOKEN_HANDOFF_PATH = AuthEndpointPaths.oneTimeTokenHandoff; -/** The vendor plugin's redemption route under the auth base path. */ -export const ONE_TIME_TOKEN_VERIFY_PATH = '/one-time-token/verify'; +/** The vendor plugin's redemption route under the auth base path, as the spec names it. */ +export const ONE_TIME_TOKEN_VERIFY_PATH = AuthEndpointPaths.oneTimeTokenVerify; /** The vendor plugin's generate route; refused over HTTP (`disableClientRequest`). */ export const ONE_TIME_TOKEN_GENERATE_PATH = '/one-time-token/generate'; @@ -188,26 +190,24 @@ export async function oneTimeTokenStoreHasher(input: string): Promise { return bindingKey(token, s256Challenge(codeVerifier)); } -/** - * A registered app scheme, as RFC 8252 §7.1 requires of a private-use scheme: - * lowercase RFC 3986 scheme syntax containing a dot (reverse-domain, e.g. - * `com.example.app`), with no `:` suffix. The reverse-domain rule is also the - * first interception defence: a scheme named for a domain the app's - * publisher controls is one another app has no business claiming. - */ -const APP_SCHEME_SYNTAX = /^[a-z][a-z0-9+.-]*$/; - /** * Why `entry` cannot be a registered app scheme, or `undefined` when it can. - * The same rules the spec's `AuthConfig.trustedAppSchemes` refuses at parse; - * repeated here because the auth manager also receives plain objects. + * The rule is the spec's own {@link TrustedAppSchemeSchema} (RFC 8252 §7.1: + * a lowercase reverse-domain private-use scheme such as `com.example.app`, + * with no `:` suffix), imported, never copied: the auth manager also + * receives plain objects that never passed through `AuthConfigSchema`, so + * it runs the same schema again. The reverse-domain rule is also the first + * interception defence: a scheme named for a domain the app's publisher + * controls is one another app has no business claiming. + * {@link NEVER_APP_SCHEMES} is checked first only for the clearer message — + * none of them contains a dot, so the schema refuses each one anyway. */ export function appSchemeRefusal(entry: unknown): string | undefined { if (typeof entry !== 'string') return 'is not a string'; if (NEVER_APP_SCHEMES.includes(entry.toLowerCase().replace(/:.*$/, ''))) { return 'is a web or script scheme the browser itself handles, never an app scheme'; } - if (!APP_SCHEME_SYNTAX.test(entry) || !entry.includes('.')) { + if (!TrustedAppSchemeSchema.safeParse(entry).success) { return 'is not a lowercase reverse-domain scheme with no ":" suffix (RFC 8252 §7.1), e.g. "com.example.app"'; } return undefined;