Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/29997-handoff-signin-entry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@objectstack/plugin-auth': patch
'@objectstack/spec': patch
'@object-ui/console': patch
---

feat(plugin-auth): a page navigation to the mobile sign-in hand-off that has to sign in first goes to the sign-in page, so the app opens only the hand-off

Clause-②: no
Metadata-protocol: none

`GET /api/v1/auth/one-time-token/handoff` answered a missing or old session with `401` as raw JSON, so the mobile app had to open the Console's `/login` route itself and know how that page treats `redirect`. An installed app stays in use for months, so a Console upgrade could strand it. Now the app opens only the hand-off:

- A page navigation with no session, or with one older than five minutes, answers `302` to this deployment's Console sign-in page, with `redirect` naming the same request. `Location` is root-relative, so the browser stays on the origin the request arrived on, and `uiBasePath` is honoured.
- Any other request still answers `401`, so the app's anonymous probe keeps reading "this server registered me". A navigation reporting `Sec-Fetch-Site: same-origin` answers `401` too: it is the sign-in page forwarding a session the route refuses. A deployment that does not serve the Console keeps the `401` too, and says so once at `warn`.
- A token is still minted only for a session under five minutes old, and every `400` (a cross-site navigation, an unknown parameter, an unregistered scheme, a bad challenge) is still decided first, with no redirect.
- A device whose clock is behind does not bounce between the route and the sign-in page. The redirect carries `handoff_refused_at`, the server's time of the refusal, and the page holds its form for any session not created after it, whatever the device's clock says. A session created after it forwards, so a provider's sign-in that returns to the same URL still ends at the app. The links between the sign-in and sign-up pages carry the mark beside a hand-off `redirect`, and every other link is unchanged.

An app can change its entry only once its server runs this release: an older server answers the new entry with JSON.
113 changes: 113 additions & 0 deletions examples/app-showcase/e2e/mobile-signin-handoff.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { test, expect } from '@playwright/test';

// Ambient `process` for the env reads below: the showcase tsconfig does not pull
// in `@types/node` (same idiom and reason as the specs beside this file).
declare const process: { env: Record<string, string | undefined> };

/**
* The mobile sign-in, end to end, in a real browser against the real Console: the
* app opens the hand-off route and nothing else, the signed-out browser lands on
* the sign-in page, and after the sign-in the route hands a token to the app.
*
* An installed app stays in use for months while the server under it upgrades, so
* this is what notices a Console upgrade breaking it for those people: a sign-in
* route that moved, or a page that stopped treating `redirect` as the hand-off.
* No unit test holds both ends. It runs in the nightly `live` tier
* (`test/showcase-smoke.live.test.ts`), the one lane where the backend serves the
* Console at its real mount, as a deployment does: `Live E2E UI` serves it from
* `vite preview` at the root, where the route's redirect has nothing to land on.
*
* On a server that did not boot with `playwright.config.ts`'s
* `OS_TRUSTED_APP_SCHEMES`, such as one `reuseExistingServer` attached to, the first
* assertion fails with a `400`: no scheme is registered there.
*/

const API = process.env.SMOKE_API_URL || 'http://localhost:3000';
const EMAIL = process.env.SMOKE_EMAIL || 'admin@objectos.ai';
const PASSWORD = process.env.SMOKE_PASSWORD || 'admin123';

/** The scheme `playwright.config.ts` registers on its `webServer`. */
const HANDOFF_APP_SCHEME = 'com.example.showcase.e2e';
const CALLBACK = `${HANDOFF_APP_SCHEME}://auth/callback`;
/** An RFC 7636 verifier, as the app makes one, and `BASE64URL(SHA-256(verifier))`. */
const VERIFIER = 'showcase-e2e_mobile.handoff~verifier-0123456789abcdef';
const CHALLENGE = '9IIwBgnDEsLVGM-qq6wnLXO6eiMqtyyEEOJEyOGnc5M';

const HANDOFF_PATH = '/api/v1/auth/one-time-token/handoff';
const HANDOFF = `${HANDOFF_PATH}?${new URLSearchParams({
redirect_uri: CALLBACK,
code_challenge: CHALLENGE,
code_challenge_method: 'S256',
})}`;

// The hand-off exists for a browser with no session, so nothing from the suite's
// sign-in comes along.
test.use({ storageState: { cookies: [], origins: [] } });

test('a signed-out browser opens the hand-off, signs in on the sign-in page, and the route hands a token to the app', async ({ page, request }) => {
// The app's probe: not a page navigation, so it reads `401` as "this server
// registered me" and is never sent to a page.
const probe = await request.get(HANDOFF, { headers: { Accept: 'application/json' }, maxRedirects: 0 });
expect(probe.status(), await probe.text()).toBe(401);

const toApp = page.waitForResponse(
(response) =>
new URL(response.url()).pathname === HANDOFF_PATH &&
response.status() === 302 &&
(response.headers().location ?? '').startsWith(`${HANDOFF_APP_SCHEME}:`),
);

// The one URL the app opens. The browser is sent to the form on this origin.
await page.goto(HANDOFF, { waitUntil: 'domcontentloaded' });
await page.locator('#login-email').waitFor({ state: 'visible', timeout: 25_000 });
const signInPage = new URL(page.url());
expect(signInPage.origin).toBe(new URL(API).origin);
expect(signInPage.pathname).toBe('/_console/login');
expect(signInPage.searchParams.get('redirect')).toBe(HANDOFF);

await page.locator('#login-email').fill(EMAIL);
await page.locator('#login-password').fill(PASSWORD);
await page.locator('form button[type="submit"]').first().click();

// The page forwards by script, and the route answers with the app's scheme. A
// browser has no handler for that scheme, so the response is the end of the road.
const answer = await toApp;
expect((await answer.request().allHeaders())['sec-fetch-site']).toBe('same-origin');
const location = new URL(answer.headers().location);
const token = location.searchParams.get('token');
expect(token).toMatch(/^[A-Za-z0-9_-]{32}$/);

// What the app does next: the token redeems for the holder of the verifier only.
const intercepted = await request.post('/api/v1/auth/one-time-token/verify', { data: { token } });
expect(intercepted.status()).toBe(400);
const redeemed = await request.post('/api/v1/auth/one-time-token/verify', { data: { token: `${token}.${VERIFIER}` } });
expect(redeemed.status(), await redeemed.text()).toBe(200);
});

test('a sign-in made elsewhere that returns to the sign-in page forwards, on a device whose clock is ahead', async ({ page }) => {
// What a provider's sign-in does: the page hands the provider its own URL, so the
// browser comes back to that URL with a new session. This device reads that
// session as too old, so the page forwards only because it believes the route's
// `handoff_refused_at` over its own clock. A route and a page that stop agreeing
// on that name end here.
await page.clock.setFixedTime(new Date(Date.now() + 15 * 60_000));

await page.goto(HANDOFF, { waitUntil: 'domcontentloaded' });
await page.locator('#login-email').waitFor({ state: 'visible', timeout: 25_000 });
const signInPage = page.url();

const signIn = await page.context().request.post('/api/v1/auth/sign-in/email', {
data: { email: EMAIL, password: PASSWORD },
headers: { Origin: new URL(API).origin },
});
expect(signIn.status(), await signIn.text()).toBe(200);

const toApp = page.waitForResponse(
(response) =>
new URL(response.url()).pathname === HANDOFF_PATH &&
response.status() === 302 &&
(response.headers().location ?? '').startsWith(`${HANDOFF_APP_SCHEME}:`),
);
await page.goto(signInPage, { waitUntil: 'commit' });
await toApp;
});
7 changes: 7 additions & 0 deletions examples/app-showcase/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ declare const process: { env: Record<string, string | undefined> };
* when the browser cache holds a different build than this `@playwright/test`
* resolves, in which case every test dies at launch (RUNNER.md, "Environment
* facts"). Unset, Playwright resolves its own browser exactly as before.
*
* ## What it registers
*
* `OS_TRUSTED_APP_SCHEMES` on the `webServer`: the one app scheme
* `e2e/mobile-signin-handoff.spec.ts` hands a token to, set the way a deployment
* sets it. No other spec reaches the hand-off.
*/
const BASE_URL = new URL(process.env.SMOKE_API_URL || 'http://localhost:3000');
const PORT = BASE_URL.port || '3000';
Expand Down Expand Up @@ -58,6 +64,7 @@ export default defineConfig({
}],
webServer: {
command: `node node_modules/@objectstack/cli/bin/run.js serve --dev -p ${PORT}`,
env: { OS_TRUSTED_APP_SCHEMES: 'com.example.showcase.e2e' },
url: `${BASE_URL.origin}/api/v1/runtime/config`,
timeout: 180_000,
reuseExistingServer: !process.env.CI,
Expand Down
5 changes: 3 additions & 2 deletions examples/app-showcase/test/showcase-smoke.live.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ import { tmpdir } from 'node:os';
* `e2e/` holds the Playwright suite that drives the Console (served by the
* backend at /_console) against the real showcase stack: every nav surface
* renders cleanly (`e2e/showcase-smoke.spec.ts`), the ADR-0085 detail shapes
* (`e2e/detail-shapes.spec.ts`) and the selection-bar capability gate
* (`e2e/bulk-capability-gate.spec.ts`). It used to run in its own scheduled
* (`e2e/detail-shapes.spec.ts`), the selection-bar capability gate
* (`e2e/bulk-capability-gate.spec.ts`) and the mobile sign-in through the real
* sign-in page (`e2e/mobile-signin-handoff.spec.ts`). It used to run in its own scheduled
* workflow, which filed nothing when it went red, so a red was read by nobody.
* This file is how the same suite runs under Nightly Tiers instead
* (`.github/workflows/test-nightly-tiers.yml`): the `*.live.test.*` name puts
Expand Down
30 changes: 26 additions & 4 deletions packages/plugins/plugin-auth/src/auth-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,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 { resolveHandoffSignInEntry, runOneTimeTokenHandoff } from './one-time-token-handoff.js';
import { readJsonRequestBody } from './request-json-body.js';
import {
seedCreatedOrganizationCurrency,
Expand Down Expand Up @@ -4030,10 +4030,13 @@ 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. 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
// target is refused with a 400 that says how to register one. Only a session
// signed in within the last five minutes is handed off, and a cross-site
// navigation is refused (ruling A). A page navigation without such a session
// goes to this deployment's sign-in page and comes back, so the app opens this
// route and never names the Console's. The reasons for every answer live in
// `one-time-token-handoff.ts`. Ledgered in auth-route-ledger.ts.
let reportedNoHandoffSignInPage = false;
rawApp.get(`${basePath}/one-time-token/handoff`, async (c: any) => {
try {
const manager = this.authManager!;
Expand All @@ -4050,6 +4053,25 @@ export class AuthPlugin implements Plugin {
const api: any = await manager.getApi();
return api.generateOneTimeToken({ headers });
},
signInEntry: (headers) =>
resolveHandoffSignInEntry({
signInPageUrl: manager.getConsoleUrl('/login'),
authBaseUrl: manager.getAuthIssuer(),
authMountPath: basePath,
requestUrl: c.req.url,
requestHost: headers.get('host'),
server: rawApp,
serverEnv: c.env,
onUnserved: (path, reason) => {
if (reportedNoHandoffSignInPage) return;
reportedNoHandoffSignInPage = true;
ctx.logger.warn(
`[AuthPlugin] one-time-token/handoff: this server does not serve a sign-in page at ${path} (${reason}), ` +
'so a signed-out browser that opens the mobile sign-in hand-off sees a JSON 401 instead of a sign-in form. ' +
'Mount the Console, or set uiBasePath to where it is mounted. Reported once.',
);
},
}),
},
{ queries: (c.req.queries() ?? {}) as Record<string, string[]>, headers: c.req.raw.headers },
);
Expand Down
2 changes: 1 addition & 1 deletion packages/plugins/plugin-auth/src/auth-route-ledger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,7 @@ export const AUTH_ROUTE_LEDGER: readonly AuthRouteLedgerEntry[] = [
// `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: '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 — it is the entry the mobile app shell opens in the system browser. Closed query set { redirect_uri, code_challenge, code_challenge_method }: for a session signed in within five minutes it 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. With no such session, a page navigation answers 302 to this deployment\'s Console sign-in page (root-relative, redirect naming the same request); any other caller, a navigation reporting Sec-Fetch-Site: same-origin (the sign-in page forwarding), and every caller on a deployment that serves no sign-in page answer 401. 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
Loading
Loading