Skip to content
17 changes: 17 additions & 0 deletions .changeset/29714-mobile-sign-in-handoff.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@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`.
- **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`** (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.
27 changes: 27 additions & 0 deletions packages/plugins/plugin-auth/src/auth-manager-plugin-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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';

Expand Down Expand Up @@ -802,6 +809,26 @@ export async function buildPluginList(this: PluginListHost): Promise<any[]> {
});
}

// 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.
Expand Down
19 changes: 19 additions & 0 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -1603,6 +1604,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
Expand Down Expand Up @@ -1782,6 +1789,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 }));
}
Expand Down Expand Up @@ -2059,6 +2075,9 @@ export class AuthManager {
if ('plugins' in patch) {
assertScimAdminCoherence(next.plugins);
}
if ('trustedAppSchemes' in patch) {
assertTrustedAppSchemes(next.trustedAppSchemes);
}

this.config = next;
this.deploymentDeclaredVerificationOff = deploymentDeclaredVerificationOff;
Expand Down
45 changes: 45 additions & 0 deletions packages/plugins/plugin-auth/src/auth-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -4013,6 +4014,50 @@ 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. 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 {
const manager = this.authManager!;
const answer = await runOneTimeTokenHandoff(
{
registeredSchemes: manager.getRegisteredAppSchemes(),
sessionCreatedAt: async (headers) => {
const api: any = await manager.getApi();
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();
return api.generateOneTimeToken({ headers });
},
},
{ queries: (c.req.queries() ?? {}) as Record<string, string[]>, 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions packages/plugins/plugin-auth/src/auth-route-ledger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions packages/plugins/plugin-auth/src/better-auth-schema-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -244,6 +246,18 @@ const AUTH_MANAGER_PLUGINS: Record<string, { construct: () => 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 }) => ({
Expand Down
14 changes: 14 additions & 0 deletions packages/plugins/plugin-auth/src/managed-extension-fields.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -507,6 +509,18 @@ const AUTH_MANAGER_PLUGINS: Record<string, { construct: () => 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 }) => ({
Expand Down
Loading
Loading