diff --git a/.changeset/29997-handoff-signin-entry.md b/.changeset/29997-handoff-signin-entry.md new file mode 100644 index 00000000000..44f2a33ba85 --- /dev/null +++ b/.changeset/29997-handoff-signin-entry.md @@ -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. diff --git a/examples/app-showcase/e2e/mobile-signin-handoff.spec.ts b/examples/app-showcase/e2e/mobile-signin-handoff.spec.ts new file mode 100644 index 00000000000..9a9040c7e5e --- /dev/null +++ b/examples/app-showcase/e2e/mobile-signin-handoff.spec.ts @@ -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 }; + +/** + * 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; +}); diff --git a/examples/app-showcase/playwright.config.ts b/examples/app-showcase/playwright.config.ts index 8166780ab65..be37cc9c003 100644 --- a/examples/app-showcase/playwright.config.ts +++ b/examples/app-showcase/playwright.config.ts @@ -31,6 +31,12 @@ declare const process: { env: Record }; * 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'; @@ -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, diff --git a/examples/app-showcase/test/showcase-smoke.live.test.ts b/examples/app-showcase/test/showcase-smoke.live.test.ts index 69e1f0670e0..f8199ae7375 100644 --- a/examples/app-showcase/test/showcase-smoke.live.test.ts +++ b/examples/app-showcase/test/showcase-smoke.live.test.ts @@ -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 diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index 88d6803138e..1a10724e0d2 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -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, @@ -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!; @@ -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, headers: c.req.raw.headers }, ); diff --git a/packages/plugins/plugin-auth/src/auth-route-ledger.ts b/packages/plugins/plugin-auth/src/auth-route-ledger.ts index a6fe99f434d..aa69f8f2f27 100644 --- a/packages/plugins/plugin-auth/src/auth-route-ledger.ts +++ b/packages/plugins/plugin-auth/src/auth-route-ledger.ts @@ -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' }, 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 e6c6fbd4b48..002accad15b 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 @@ -25,6 +25,7 @@ import { createMemoryEngine } from './impersonation-bearer-rotation.test'; import { inviteForAudienceGate } from './audience-gate-test-support'; import { memoizeScrypt } from './__tests__/scrypt-memo.testkit'; import { + HANDOFF_REFUSED_AT_PARAM, NEVER_APP_SCHEMES, ONE_TIME_TOKEN_EXPIRES_IN_MINUTES, ONE_TIME_TOKEN_HANDOFF_SESSION_MAX_AGE_SECONDS, @@ -33,7 +34,9 @@ import { ONE_TIME_TOKEN_VERIFY_PATH, appSchemeRefusal, assertTrustedAppSchemes, + isPageNavigation, oneTimeTokenStoreHasher, + resolveHandoffSignInEntry, runOneTimeTokenHandoff, s256Challenge, } from './one-time-token-handoff'; @@ -59,8 +62,8 @@ const mockCtx = (): PluginContext => 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 makeManager = (engine: any, trustedAppSchemes: string[] | undefined = [APP_SCHEME], extra: Record = {}) => + new AuthManager({ secret: SECRET, baseUrl: ORIGIN, dataEngine: engine, trustedAppSchemes, ...extra } as any); const direct = (manager: AuthManager, path: string, init: RequestInit = {}) => manager.handleRequest(new Request(`${ORIGIN}${BASE}${path}`, init)); @@ -89,13 +92,20 @@ const signedUpOnce = (): Promise> => return template.tables as Map; })()); -const arrange = async (trustedAppSchemes?: string[]) => { +const arrange = async ( + trustedAppSchemes?: string[], + { managerConfig, mountConsole }: { managerConfig?: Record; mountConsole?: (app: Hono) => void } = {}, +) => { const engine = createMemoryEngine(); for (const [name, rows] of await signedUpOnce()) engine.tables.set(name, structuredClone(rows)); - const manager = makeManager(engine, trustedAppSchemes); + const manager = makeManager(engine, trustedAppSchemes, managerConfig); // The REAL route registration — raw mounts ahead of the catch-all. const app = new Hono(); + // What this composition serves besides the API, on the same app, as `os serve` + // mounts the Console beside these routes. A Hono app takes no route after its + // first request, so it goes on here. + mountConsole?.(app); const ctx = mockCtx(); const plugin = new AuthPlugin({ secret: SECRET }); await plugin.init(ctx); @@ -108,7 +118,7 @@ const arrange = async (trustedAppSchemes?: string[]) => { return cookieFrom(res); }; - /** GET the hand-off through the Hono app, as the system browser does. */ + /** GET the hand-off through the Hono app. A browser's navigation headers go in `extra`. */ const handoff = (cookie: string | undefined, query: Record, extra: Record = {}) => app.request(`${ORIGIN}${BASE}${ONE_TIME_TOKEN_HANDOFF_PATH}?${new URLSearchParams(query)}`, { headers: { ...(cookie ? { cookie } : {}), ...extra }, @@ -125,7 +135,20 @@ const arrange = async (trustedAppSchemes?: string[]) => { const redeem = (token: string) => postJson(manager, ONE_TIME_TOKEN_VERIFY_PATH, { token }); - return { engine, manager, signIn, handoff, handOff, redeem }; + return { engine, manager, app, ctx, signIn, handoff, handOff, redeem }; +}; + +/** What a browser's address bar or a `location.assign` sends: Fetch Metadata and an HTML `Accept`. */ +const NAVIGATION = { + 'sec-fetch-dest': 'document', + 'sec-fetch-mode': 'navigate', + 'sec-fetch-site': 'none', + accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', +}; + +/** What `os serve` mounts when the Console is built: an HTML document on every page path. */ +const servedConsole = (app: Hono) => { + app.get('/_console/*', (c) => c.html('Console')); }; beforeEach(() => { @@ -252,7 +275,7 @@ describe('one-time-token hand-off — the redirect goes to a registered app sche expect(res.headers.get('location')).toBeNull(); }); - it('refuses a signed-out caller with 401, after the request itself is valid', async () => { + it('answers a signed-out caller that is not a page navigation 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); @@ -263,7 +286,7 @@ 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 () => { + it(`answers a session older than ${ONE_TIME_TOKEN_HANDOFF_SESSION_MAX_AGE_SECONDS} seconds with 401 and mints nothing, for a caller that is not a page navigation`, async () => { const { engine, signIn, handoff } = await arrange(); const cookie = await signIn(); const before = verificationRows(engine).length; @@ -304,9 +327,362 @@ describe('one-time-token hand-off — only a fresh sign-in, opened by the app it }); }); +describe('one-time-token hand-off — a page navigation without a fresh session goes to the sign-in page', () => { + const query = { redirect_uri: CALLBACK, code_challenge: s256Challenge(VERIFIER), code_challenge_method: 'S256' }; + const handoffPath = `${BASE}${ONE_TIME_TOKEN_HANDOFF_PATH}`; + + /** The answer's `Location`, split into what it names. */ + const signInTarget = (res: Response) => { + const location = res.headers.get('location')!; + const url = new URL(location, 'http://never.example'); + return { location, path: url.pathname, query: url.searchParams, redirect: url.searchParams.get('redirect')! }; + }; + + it('sends a signed-out navigation there, naming the same request and minting nothing', async () => { + const { engine, handoff } = await arrange(undefined, { mountConsole: servedConsole }); + const before = verificationRows(engine).length; + const res = await handoff(undefined, query, NAVIGATION); + expect(res.status).toBe(302); + expect(res.headers.get('cache-control')).toBe('no-store'); + expect(res.headers.get('referrer-policy')).toBe('no-referrer'); + const target = signInTarget(res); + expect(target.path).toBe('/_console/login'); + expect(target.redirect).toBe(`${handoffPath}?${new URLSearchParams(query)}`); + expect(verificationRows(engine).length).toBe(before); + }); + + it(`sends a session older than ${ONE_TIME_TOKEN_HANDOFF_SESSION_MAX_AGE_SECONDS} seconds there too, minting nothing`, async () => { + const { engine, signIn, handoff } = await arrange(undefined, { mountConsole: servedConsole }); + 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, NAVIGATION); + expect(res.status).toBe(302); + expect(signInTarget(res).path).toBe('/_console/login'); + expect(signInTarget(res).query.get(HANDOFF_REFUSED_AT_PARAM)).toBe(String(Date.now())); + expect(verificationRows(engine).length).toBe(before); + }); + + it('still hands a fresh session straight to the app: the sign-in page is not in its path', async () => { + const { signIn, handoff } = await arrange(undefined, { mountConsole: servedConsole }); + const res = await handoff(await signIn(), query, NAVIGATION); + expect(res.status).toBe(302); + expect(new URL(res.headers.get('location')!).protocol).toBe(`${APP_SCHEME}:`); + }); + + it('the redirect it names is a request this route accepts: signing in and following it ends at the app, and the token redeems', async () => { + const { signIn, handoff, redeem } = await arrange(undefined, { mountConsole: servedConsole }); + const { redirect } = signInTarget(await handoff(undefined, query, NAVIGATION)); + const named = new URL(redirect, ORIGIN); + expect(named.pathname).toBe(handoffPath); + + const cookie = await signIn(); + const res = await handoff(cookie, Object.fromEntries(named.searchParams), { ...NAVIGATION, 'sec-fetch-site': 'same-origin' }); + expect(res.status).toBe(302); + const location = new URL(res.headers.get('location')!); + expect(location.protocol).toBe(`${APP_SCHEME}:`); + expect((await redeem(`${location.searchParams.get('token')}.${VERIFIER}`)).status).toBe(200); + }); + + it('the redirect is in the form the sign-in page matches: root-relative, one path, and stable when the page re-serializes it', async () => { + const { handoff } = await arrange(undefined, { mountConsole: servedConsole }); + const { redirect, location } = signInTarget(await handoff(undefined, query, NAVIGATION)); + expect(redirect.startsWith('/')).toBe(true); + expect(redirect.startsWith('//')).toBe(false); + const parsed = new URL(redirect, ORIGIN); + expect(parsed.origin).toBe(ORIGIN); + expect(`${parsed.pathname}${parsed.search}`).toBe(redirect); + // `client_id` + `redirect_uri` at the top of the query are what the sign-in + // page reads as an OAuth authorize replay, so the app's `redirect_uri` stays + // inside `redirect`. + expect(new URL(location, ORIGIN).searchParams.has('redirect_uri')).toBe(false); + }); + + it('rebuilds redirect from the validated parameters and leaves code_challenge_method out when the request did', async () => { + const { handoff } = await arrange(undefined, { mountConsole: servedConsole }); + const res = await handoff(undefined, { redirect_uri: CALLBACK, code_challenge: query.code_challenge }, NAVIGATION); + expect(signInTarget(res).redirect).toBe( + `${handoffPath}?${new URLSearchParams({ redirect_uri: CALLBACK, code_challenge: query.code_challenge })}`, + ); + }); + + it('stays on the origin the request arrived on while honouring uiBasePath: the canonical origin never reaches Location', async () => { + const { handoff } = await arrange(undefined, { + managerConfig: { baseUrl: 'https://canonical.example.com', uiBasePath: '/console/' }, + mountConsole: (app) => { + app.get('/console/*', (c) => c.html('Console')); + }, + }); + const res = await handoff(undefined, query, NAVIGATION); + expect(res.status).toBe(302); + const { location, path } = signInTarget(res); + expect(location.startsWith('/console/login?')).toBe(true); + expect(location).not.toContain('canonical.example.com'); + expect(path).toBe('/console/login'); + }); + + it.each([ + ['Accept: application/json, the app\'s probe', { accept: 'application/json' }], + ['no Accept and no Fetch Metadata', {}], + ['Accept: */*', { accept: '*/*' }], + ['a fetch asking for HTML', { accept: 'text/html', 'sec-fetch-dest': 'empty', 'sec-fetch-mode': 'cors' }], + ['an iframe', { accept: 'text/html', 'sec-fetch-dest': 'iframe', 'sec-fetch-mode': 'navigate' }], + ])('answers %s with 401 and no redirect, with a Console mounted', async (_label, headers) => { + const { handoff } = await arrange(undefined, { mountConsole: servedConsole }); + const res = await handoff(undefined, query, headers as Record); + expect(res.status).toBe(401); + expect(((await res.json()) as any).error.code).toBe('UNAUTHENTICATED'); + expect(res.headers.get('location')).toBeNull(); + }); + + it('a browser that sends no Fetch Metadata still counts: Accept names text/html', async () => { + const { handoff } = await arrange(undefined, { mountConsole: servedConsole }); + const res = await handoff(undefined, query, { accept: 'text/html,application/xhtml+xml,*/*;q=0.8' }); + expect(res.status).toBe(302); + expect(signInTarget(res).path).toBe('/_console/login'); + }); + + describe('no refusal moves behind the redirect', () => { + it.each([ + ['an unregistered scheme', { ...query, redirect_uri: 'com.attacker.app://cb' }], + ['a web scheme', { ...query, redirect_uri: 'https://attacker.example/cb' }], + ['a malformed challenge', { ...query, code_challenge: 'short' }], + ['a plain challenge method', { ...query, code_challenge_method: 'plain' }], + ['an unknown parameter', { ...query, scope: 'x' }], + ])('%s gets 400 and never reaches the sign-in page', async (_label, bad) => { + const { handoff } = await arrange(undefined, { mountConsole: servedConsole }); + const res = await handoff(undefined, bad as Record, NAVIGATION); + expect(res.status).toBe(400); + expect(((await res.json()) as any).error.code).toBe('VALIDATION_ERROR'); + expect(res.headers.get('location')).toBeNull(); + }); + + it('cross-site is judged first: it gets 400 even on a request that is otherwise invalid', async () => { + const { handoff } = await arrange(undefined, { mountConsole: servedConsole }); + for (const q of [query, { redirect_uri: 'https://attacker.example/cb' }, { ...query, scope: 'x' }]) { + const res = await handoff(undefined, q as Record, { ...NAVIGATION, 'sec-fetch-site': 'cross-site' }); + expect(res.status).toBe(400); + const { message } = ((await res.json()) as any).error; + expect(message).toContain('cannot be opened from another site'); + expect(res.headers.get('location')).toBeNull(); + } + }); + + it('with no registered scheme there is no entry either: every target is refused', async () => { + const { handoff } = await arrange([], { mountConsole: servedConsole }); + const res = await handoff(undefined, query, NAVIGATION); + expect(res.status).toBe(400); + expect(res.headers.get('location')).toBeNull(); + }); + }); + + describe('it cannot bounce', () => { + it('a navigation the sign-in page forwarded with a refused session gets 401, never another redirect', async () => { + const { signIn, handoff } = await arrange(undefined, { mountConsole: servedConsole }); + const cookie = await signIn(); + vi.useFakeTimers({ toFake: ['Date'], now: Date.now() + ONE_TIME_TOKEN_HANDOFF_SESSION_MAX_AGE_SECONDS * 1000 + 1_000 }); + const forwarded = { ...NAVIGATION, 'sec-fetch-site': 'same-origin' }; + for (const session of [cookie, undefined]) { + const res = await handoff(session, query, forwarded); + expect(res.status).toBe(401); + expect(((await res.json()) as any).error.code).toBe('UNAUTHENTICATED'); + expect(res.headers.get('location')).toBeNull(); + } + }); + + it('the mark on the redirect is the refusal\'s time on the server\'s clock, so the page never has to read the device\'s', async () => { + const refusedAt = 1_900_000_000_000; + const answer = await runOneTimeTokenHandoff( + { + registeredSchemes: [APP_SCHEME], + sessionCreatedAt: async () => new Date(refusedAt - ONE_TIME_TOKEN_HANDOFF_SESSION_MAX_AGE_SECONDS * 1000 - 1), + now: () => refusedAt, + generate: async () => ({ token: 'never' }), + signInEntry: async () => ({ signInPage: '/_console/login', handoff: handoffPath }), + }, + { queries: { redirect_uri: [CALLBACK], code_challenge: [query.code_challenge] }, headers: new Headers(NAVIGATION) }, + ); + expect(answer.status).toBe(302); + expect(new URL(answer.headers.Location, ORIGIN).searchParams.get(HANDOFF_REFUSED_AT_PARAM)).toBe(String(refusedAt)); + }); + }); + + describe('a deployment with no Console keeps the 401', () => { + it.each([ + ['no route at all (--no-ui)', undefined], + ['the 503 answer of a Console that was not built', (app: Hono) => { + app.get('/_console/*', (c) => c.text('The ObjectStack Console is not built', 503)); + }], + ['a redirect elsewhere', (app: Hono) => { + app.get('/_console/*', (c) => c.redirect('https://control-plane.example/_console/', 302)); + }], + ['a 200 that is not a document', (app: Hono) => { + app.get('/_console/*', (c) => c.json({ ok: true })); + }], + ])('%s: JSON 401, no redirect, said once', async (_label, mountConsole) => { + const { ctx, handoff } = await arrange(undefined, { mountConsole }); + for (let attempt = 0; attempt < 2; attempt += 1) { + const res = await handoff(undefined, query, NAVIGATION); + expect(res.status).toBe(401); + expect(((await res.json()) as any).error.code).toBe('UNAUTHENTICATED'); + expect(res.headers.get('location')).toBeNull(); + } + const said = (ctx.logger.warn as any).mock.calls.filter(([line]: [string]) => line.includes('one-time-token/handoff')); + expect(said).toHaveLength(1); + expect(said[0][0]).toContain('/_console/login'); + }); + + it('a middleware that reads an execution context the probe does not carry keeps the 401 and says why', async () => { + const { ctx, app } = await arrange(undefined, { + mountConsole: (composed) => { + composed.use('*', async (c, next) => { + void c.executionCtx; // Hono's getter throws when the request has none. + await next(); + }); + servedConsole(composed); + }, + }); + const executionCtx = { waitUntil: () => {}, passThroughOnException: () => {} } as any; + const res = await app.fetch( + new Request(`${ORIGIN}${BASE}${ONE_TIME_TOKEN_HANDOFF_PATH}?${new URLSearchParams(query)}`, { headers: NAVIGATION }), + undefined, + executionCtx, + ); + expect(res.status).toBe(401); + expect(res.headers.get('location')).toBeNull(); + const said = (ctx.logger.warn as any).mock.calls.filter(([line]: [string]) => line.includes('one-time-token/handoff')); + expect(said).toHaveLength(1); + expect(said[0][0]).toContain('it answers 500'); + }); + + it.each([ + ['a baseUrl ending in //', { baseUrl: 'https://canonical.example.com//' }], + ['a uiBasePath starting with //', { uiBasePath: '//elsewhere.example' }], + ])('%s would make Location protocol-relative: JSON 401, no redirect, said once', async (_label, managerConfig) => { + const { ctx, handoff } = await arrange(undefined, { managerConfig, mountConsole: servedConsole }); + for (let attempt = 0; attempt < 2; attempt += 1) { + const res = await handoff(undefined, query, NAVIGATION); + expect(res.status).toBe(401); + expect(res.headers.get('location')).toBeNull(); + } + const said = (ctx.logger.warn as any).mock.calls.filter(([line]: [string]) => line.includes('one-time-token/handoff')); + expect(said).toHaveLength(1); + expect(said[0][0]).toContain('protocol-relative'); + }); + + it('a uiBasePath that composes no URL refuses the same way instead of failing the request', async () => { + const { handoff } = await arrange(undefined, { managerConfig: { uiBasePath: '_console' } }); + const res = await handoff(undefined, query, NAVIGATION); + expect(res.status).toBe(401); + }); + }); +}); + +describe('isPageNavigation', () => { + it.each<[string, Record, boolean]>([ + ['Sec-Fetch-Dest: document', { 'sec-fetch-dest': 'document' }, true], + ['Sec-Fetch-Dest: document with a wildcard Accept', { 'sec-fetch-dest': 'document', accept: '*/*' }, true], + ['case and spaces in the value', { 'sec-fetch-dest': ' Document ' }, true], + ['Sec-Fetch-Dest: empty outranks an HTML Accept', { 'sec-fetch-dest': 'empty', accept: 'text/html' }, false], + ['Sec-Fetch-Dest: iframe', { 'sec-fetch-dest': 'iframe', accept: 'text/html' }, false], + ['a browser with no Fetch Metadata', { accept: 'text/html,application/xhtml+xml,*/*;q=0.8' }, true], + ['a media type with parameters', { accept: 'text/html;q=0.9' }, true], + ['the app\'s probe', { accept: 'application/json' }, false], + ['a wildcard', { accept: '*/*' }, false], + ['a lookalike type', { accept: 'text/htmlx, application/text/html' }, false], + ['no header at all', {}, false], + ])('%s', (_label, headers, expected) => { + expect(isPageNavigation(new Headers(headers))).toBe(expected); + }); +}); + +describe('resolveHandoffSignInEntry', () => { + const source = (fetch: (request: Request) => Response, overrides: Record = {}) => ({ + signInPageUrl: 'https://canonical.example.com/_console/login', + authBaseUrl: 'https://canonical.example.com/api/v1/auth', + authMountPath: '/api/v1/auth', + requestUrl: 'https://arrived-on.example.com/api/v1/auth/one-time-token/handoff?x=1', + requestHost: 'arrived-on.example.com', + server: { fetch }, + onUnserved: vi.fn(), + ...overrides, + }); + const documentAnswer = () => new Response('', { headers: { 'content-type': 'text/html; charset=utf-8' } }); + + it('asks the composed server about the page as this browser would reach it, carrying no credential', async () => { + const asked: Request[] = []; + await resolveHandoffSignInEntry(source((request) => { + asked.push(request); + return documentAnswer(); + })); + expect(asked).toHaveLength(1); + expect(asked[0].url).toBe('https://arrived-on.example.com/_console/login'); + expect(asked[0].headers.get('host')).toBe('arrived-on.example.com'); + expect(asked[0].headers.get('accept')).toBe('text/html'); + expect(asked[0].headers.get('cookie')).toBeNull(); + }); + + it('answers only paths, so the canonical origin goes nowhere', async () => { + expect(await resolveHandoffSignInEntry(source(documentAnswer))).toEqual({ + signInPage: '/_console/login', + handoff: `/api/v1/auth${ONE_TIME_TOKEN_HANDOFF_PATH}`, + }); + }); + + it('a base path in baseUrl stays in what the browser is told and out of what this process is asked', async () => { + const asked: string[] = []; + const entry = await resolveHandoffSignInEntry(source( + (request) => { + asked.push(new URL(request.url).pathname); + return documentAnswer(); + }, + { + signInPageUrl: 'https://canonical.example.com/erp/_console/login', + authBaseUrl: 'https://canonical.example.com/erp/api/v1/auth', + }, + )); + expect(entry).toEqual({ signInPage: '/erp/_console/login', handoff: `/erp/api/v1/auth${ONE_TIME_TOKEN_HANDOFF_PATH}` }); + expect(asked).toEqual(['/_console/login']); + }); + + it('a configured trailing slash on the auth base does not double in the path', async () => { + const entry = await resolveHandoffSignInEntry(source(documentAnswer, { + authBaseUrl: 'https://canonical.example.com/api/v1/auth/', + })); + expect(entry?.handoff).toBe(`/api/v1/auth${ONE_TIME_TOKEN_HANDOFF_PATH}`); + }); + + it('says why when the page is not served', async () => { + const inputs = source(() => new Response('gone', { status: 404 })); + expect(await resolveHandoffSignInEntry(inputs)).toBeNull(); + expect(inputs.onUnserved).toHaveBeenCalledWith('/_console/login', 'it answers 404'); + }); + + it.each([ + ['the sign-in page', { signInPageUrl: 'https://canonical.example.com//_console/login' }], + ['the hand-off', { authBaseUrl: 'https://canonical.example.com//api/v1/auth' }], + ])('a path starting with // on %s is no entry, and the server is not asked', async (_label, overrides) => { + const fetch = vi.fn(documentAnswer); + const inputs = source(fetch, overrides); + expect(await resolveHandoffSignInEntry(inputs)).toBeNull(); + expect(fetch).not.toHaveBeenCalled(); + expect(inputs.onUnserved).toHaveBeenCalledTimes(1); + expect((inputs.onUnserved as any).mock.calls[0][1]).toContain('protocol-relative'); + }); + + it('a server that throws when asked is a page it does not serve, not a failed hand-off', async () => { + const inputs = source(() => { + throw new Error('boom'); + }); + expect(await resolveHandoffSignInEntry(inputs)).toBeNull(); + expect(inputs.onUnserved).toHaveBeenCalledWith('/_console/login', 'asking for it threw: boom'); + }); +}); + 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], sessionCreatedAt: async () => new Date(), generate }; + const signInEntry = vi.fn(async () => ({ signInPage: '/_console/login', handoff: `${BASE}${ONE_TIME_TOKEN_HANDOFF_PATH}` })); + const deps = { registeredSchemes: [APP_SCHEME], sessionCreatedAt: async () => new Date(), generate, signInEntry }; const ask = (queries: Record) => runOneTimeTokenHandoff(deps, { queries, headers: new Headers() }); const challenge = s256Challenge(VERIFIER); @@ -325,6 +701,7 @@ describe('one-time-token hand-off — the request is validated before anything i expect((answer.body as any).error.code).toBe('VALIDATION_ERROR'); expect(answer.headers.Location).toBeUndefined(); expect(generate).not.toHaveBeenCalled(); + expect(signInEntry).not.toHaveBeenCalled(); }); }); 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 ad5f8e97e78..d404d145a49 100644 --- a/packages/plugins/plugin-auth/src/one-time-token-handoff.ts +++ b/packages/plugins/plugin-auth/src/one-time-token-handoff.ts @@ -19,8 +19,9 @@ * 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. + * with `C = BASE64URL(SHA-256(code_verifier))`. That URL is the shell's only + * entry: a browser that has yet to sign in is sent to the sign-in page from + * here, and comes back here afterwards (see "The sign-in entry" below). * 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), @@ -58,12 +59,12 @@ * 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. + * - **Freshness.** A token is minted only for a session created within + * {@link ONE_TIME_TOKEN_HANDOFF_SESSION_MAX_AGE_SECONDS}. An older one is + * refused the way no session is, because the remedy is the same: a sign-in. + * So every hand-off follows a full sign-in in the system auth session (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 @@ -74,6 +75,64 @@ * (`disableClientRequest`), and a server-side call made outside this route's * scope stores a key no redemption can reach, so it fails closed. * + * ## The sign-in entry (maintainer ruling: 「改成服务端入口」) + * + * The app opens this route and nothing else, so the Console's sign-in route + * stays internal to this repository and its release: an installed app lives + * for months, and a Console upgrade must not be able to strand it. A valid + * request whose session is missing or too old therefore answers one of two + * ways, and every `400` this route answers is decided before either: + * + * - **A page navigation** ({@link isPageNavigation}) answers `302` to this + * deployment's sign-in page, with `redirect` naming this same request, so + * the user sees a form instead of raw JSON. + * - **Anything else** answers `401 UNAUTHENTICATED`. The app's anonymous probe + * is one of these, and it reads `401` as "this server registered me". + * + * Which is which rests on no single header, because neither has been measured + * in a real `ASWebAuthenticationSession` or Custom Tabs. `Sec-Fetch-Dest` decides + * when a browser sends it (`document` for a top-level page, `empty` for a + * `fetch`); a browser without Fetch Metadata still asks for `text/html` on a + * navigation. A request carrying neither gets the `401`, which is what this + * route answered before the entry existed. + * + * Three more rules: + * + * - **The browser stays on the origin it arrived on.** The session cookie and + * the sign-in page's same-origin match of `redirect` both depend on it, so + * `Location` is root-relative. `AuthManager.getConsoleUrl` composes from the + * canonical origin; {@link resolveHandoffSignInEntry} takes only the PATH of + * its answer, which keeps `uiBasePath` and any base path in `baseUrl`. A path + * starting with `//` would name another host, so it counts as no sign-in page. + * - **No Console, no redirect.** The browser is sent to a sign-in page only when + * this process answers that page with an HTML document, asked the way the + * browser would be about to ask. `--no-ui`, a Console that was not built, a + * host that redirects the Console elsewhere and a Console served by something + * in front of this process all keep the `401`; the first refused navigation + * logs why at `warn`, once. + * - **It does not bounce.** The sign-in page's own age check reads the device's + * clock and this route reads the server's, so on a device whose clock is + * behind, that check alone would forward a session this route refuses, and a + * redirect back would repeat. Two things stop that: + * + * 1. The redirect carries {@link HANDOFF_REFUSED_AT_PARAM}, the server's time + * of the refusal, and the sign-in page holds its form for any session not + * created after it, whatever the device's clock says + * (`handoffRedirect.ts` in the Console). This reads no request header, so + * it holds with or without Fetch Metadata. On several nodes behind one host + * the time and `createdAt` come from two clocks, so skew can hold the form + * until a sign-in makes a later `createdAt`, which clears it. + * 2. A navigation reporting `Sec-Fetch-Site: same-origin` that this route still + * refuses is one the sign-in page forwarded, so it gets the `401`, never + * another redirect. + * + * The page can still forward a session this route refuses when that session + * was created after the mark: a URL reopened more than five minutes later, or a + * session the page can read and this route cannot (a bearer token with no + * cookie). A browser with Fetch Metadata stops at the `401`. A browser without + * it gets one more redirect, whose newer mark holds the form, so what follows + * waits for a person to sign in. + * * ## The registered app-scheme list * * `redirect_uri` must name a scheme the deployment registered in @@ -125,6 +184,22 @@ export const ONE_TIME_TOKEN_EXPIRES_IN_MINUTES = 1; */ export const ONE_TIME_TOKEN_HANDOFF_SESSION_MAX_AGE_SECONDS = 5 * 60; +/** + * On the redirect to the sign-in page, beside `redirect`: when this route + * refused the browser's session, in epoch milliseconds on the SERVER's clock. The + * Console's `handoffRedirect.ts` restates the name. + * + * A session whose `createdAt` is not later than this is one the route has + * already refused, whatever the device's clock says, so the page holds its form + * for it; a session made after it (a sign-in on the page, or a provider's + * sign-in that returned to the same URL) is forwarded. On one process the + * comparison is between two readings of one clock. On several nodes behind one + * host it is two clocks, so skew can hold the form until a sign-in makes a later + * `createdAt`, which clears it. The page's verdict is never a security question: + * this route refuses on its own. + */ +export const HANDOFF_REFUSED_AT_PARAM = 'handoff_refused_at'; + /** The route's CLOSED query-parameter set. */ export const ONE_TIME_TOKEN_HANDOFF_QUERY_PARAMS: readonly string[] = [ 'redirect_uri', @@ -254,6 +329,14 @@ export interface HandoffAnswer { headers: Record; } +/** Where a page navigation goes to sign in, both root-relative: see "The sign-in entry" above. */ +export interface HandoffSignInEntry { + /** This deployment's sign-in page. */ + signInPage: string; + /** This route, as the page navigates back to it. */ + handoff: string; +} + export interface HandoffDeps { /** The registered app schemes ({@link registeredAppSchemesOf}). */ registeredSchemes: readonly string[]; @@ -267,6 +350,12 @@ export interface HandoffDeps { now?: () => number; /** The vendor plugin's generate endpoint, called server-side with the caller's headers. */ generate(headers: Headers): Promise<{ token: string }>; + /** + * Where a page navigation goes to sign in, or `null` when this deployment + * serves no sign-in page ({@link resolveHandoffSignInEntry}). Asked only for a + * valid request whose session is refused. + */ + signInEntry(headers: Headers): Promise; } export interface HandoffRequest { @@ -289,6 +378,128 @@ function single(queries: Record, name: string): { val return { value: values[0] }; } +/** The request's `Sec-Fetch-Site`, `undefined` when it carries none. */ +function fetchSite(headers: Headers): string | undefined { + return headers.get('sec-fetch-site')?.trim().toLowerCase(); +} + +/** + * Whether the request is a browser navigating to a page, not a client calling an + * API. `Sec-Fetch-Dest` decides when present, so a `fetch` asking for HTML stays + * a call and an iframe is not the top-level page. A browser without Fetch + * Metadata still names `text/html` in `Accept` on a navigation; the app's probe + * (`Accept: application/json`) and a bare `curl` (which accepts anything) do not. + */ +export function isPageNavigation(headers: Headers): boolean { + const dest = headers.get('sec-fetch-dest')?.trim().toLowerCase(); + if (dest) return dest === 'document'; + return (headers.get('accept') ?? '') + .split(',') + .some((range) => range.split(';')[0].trim().toLowerCase() === 'text/html'); +} + +/** + * The sign-in entry of this deployment, when this process serves its sign-in + * page. Only the PATH of the manager's absolute URLs is taken, so the browser + * stays on the origin the request arrived on; they are never composed a second + * time, so `uiBasePath` and a base path in `baseUrl` mean what they mean in every + * link this deployment mails. + * + * Whether the page exists is asked of the composed server, per request, the way + * the Console's own document asks for its branding: a route being registered + * would not tell a Console from the `503` a server answers on the same paths when + * its build is missing. No execution context goes with it, and none is lost: + * `@hono/node-server` hands the real request `env` alone, and no code on this + * server reads `c.executionCtx`, whose getter throws when absent. A middleware + * that did would answer `500` here, which keeps the `401` and says why. + */ +export async function resolveHandoffSignInEntry(source: { + /** `AuthManager.getConsoleUrl` for the sign-in page. */ + signInPageUrl: string; + /** `AuthManager.getAuthIssuer`: the canonical origin, any base path, and the auth mount. */ + authBaseUrl: string; + /** The path this process mounts the auth routes at. */ + authMountPath: string; + requestUrl: string; + requestHost: string | null; + /** The composed server, which answers in-process, and the transport's bindings it runs under. */ + server: { fetch(request: Request, env?: unknown): Response | Promise }; + serverEnv?: unknown; + /** Told why no entry exists; the caller says so once. */ + onUnserved(path: string, reason: string): void; +}): Promise { + let signInPage: string; + let authBase: string; + try { + signInPage = new URL(source.signInPageUrl).pathname; + authBase = new URL(source.authBaseUrl).pathname.replace(/\/+$/, ''); + } catch { + source.onUnserved(source.signInPageUrl, 'it is not a URL: check uiBasePath and baseUrl'); + return null; + } + // A base path in `baseUrl` belongs to whatever is in front of this process, so + // the browser's path carries it and this process's own route does not. + const deploymentBase = authBase.endsWith(source.authMountPath) + ? authBase.slice(0, authBase.length - source.authMountPath.length) + : ''; + const localPath = deploymentBase && signInPage.startsWith(`${deploymentBase}/`) + ? signInPage.slice(deploymentBase.length) + : signInPage; + const handoff = `${authBase}${ONE_TIME_TOKEN_HANDOFF_PATH}`; + // Config alone reaches this: a `baseUrl` ending in `//` composes a path starting + // with `//`, which as a root-relative `Location` names another host. + if (signInPage.startsWith('//') || handoff.startsWith('//')) { + source.onUnserved(signInPage, 'it would be a protocol-relative Location: check baseUrl and uiBasePath'); + return null; + } + + const headers = new Headers({ accept: 'text/html' }); + if (source.requestHost) headers.set('host', source.requestHost); + let answer: Response; + try { + answer = await source.server.fetch(new Request(new URL(localPath, source.requestUrl), { headers }), source.serverEnv); + } catch (error) { + source.onUnserved(localPath, `asking for it threw: ${error instanceof Error ? error.message : String(error)}`); + return null; + } + const served = answer.status === 200 && (answer.headers.get('content-type') ?? '').toLowerCase().startsWith('text/html'); + try { + await answer.body?.cancel(); + } catch { + // The body is never read; a stream that cannot be cancelled changes nothing. + } + if (!served) { + source.onUnserved(localPath, `it answers ${answer.status}`); + return null; + } + return { signInPage, handoff }; +} + +/** + * The answer to a valid request whose session is refused: `refused` (the `401`), + * or for a page navigation the `302` to the sign-in page. See "The sign-in entry" + * in the module docblock. + */ +async function refusedSessionAnswer( + deps: HandoffDeps, + request: HandoffRequest, + validated: URLSearchParams, + now: number, + refused: HandoffAnswer, +): Promise { + if (!isPageNavigation(request.headers)) return refused; + // The sign-in page forwarded a session this route refuses, so sending it back + // could only repeat; the shell's own entry reports `none`. + if (fetchSite(request.headers) === 'same-origin') return refused; + const entry = await deps.signInEntry(request.headers); + if (entry === null) return refused; + const query = new URLSearchParams({ + redirect: `${entry.handoff}?${validated}`, + [HANDOFF_REFUSED_AT_PARAM]: String(now), + }); + return { status: 302, headers: { ...NO_STORE, Location: `${entry.signInPage}?${query}` } }; +} + /** `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 @@ -296,7 +507,7 @@ export async function runOneTimeTokenHandoff(deps: HandoffDeps, request: Handoff // 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') { + if (fetchSite(request.headers) === 'cross-site') { return refusal( 400, 'VALIDATION_ERROR', @@ -344,17 +555,35 @@ export async function runOneTimeTokenHandoff(deps: HandoffDeps, request: Handoff return refusal(400, 'VALIDATION_ERROR', 'The "code_challenge_method" query parameter accepts only "S256".'); } + // Every parameter is valid from here on, so these are what `redirect` is + // rebuilt from: nothing else the request carried reaches the sign-in page. + const validated = new URLSearchParams({ redirect_uri: redirect.value, code_challenge: challenge.value }); + if (method.value !== undefined) validated.set('code_challenge_method', method.value); + + const now = (deps.now ?? Date.now)(); 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.'); + return refusedSessionAnswer( + deps, + request, + validated, + now, + 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(); + const ageMs = 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.', + return refusedSessionAnswer( + deps, + request, + validated, + now, + 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.', + ), ); } diff --git a/packages/spec/src/api/auth-endpoints.zod.ts b/packages/spec/src/api/auth-endpoints.zod.ts index 8c78b0780ab..f64a881c5db 100644 --- a/packages/spec/src/api/auth-endpoints.zod.ts +++ b/packages/spec/src/api/auth-endpoints.zod.ts @@ -415,25 +415,40 @@ export type DeviceTokenResponse = z.input; * * Some identity providers refuse embedded WebViews, so the mobile shell opens * the sign-in in the system authentication session (`ASWebAuthenticationSession` - * on iOS, Custom Tabs on Android). Once that session is signed in, the shell - * navigates it here. The server mints a single-use token bound to the shell's - * PKCE challenge (RFC 7636) and redirects to the app's registered scheme; the - * WebView then redeems the token at `POST /one-time-token/verify` together with - * the matching verifier, so a callback intercepted by another app on the device - * cannot be redeemed without the verifier only the shell holds. + * on iOS, Custom Tabs on Android), and this route is the one URL it opens there. + * For a session signed in within the last five minutes (the system browser's + * cookie), the server mints a single-use token bound to the shell's PKCE challenge + * (RFC 7636) and redirects to the app's registered scheme; the WebView then redeems + * the token at `POST /one-time-token/verify` together with the matching verifier, + * so a callback intercepted by another app on the device cannot be redeemed without + * the verifier only the shell holds. A browser with no such session is sent to sign + * in first, and comes back here afterwards. * - * The route requires a signed-in session (the system browser's cookie). The - * parameter set is CLOSED: an unknown or repeated parameter is refused. + * The parameter set is CLOSED: an unknown or repeated parameter is refused. * - * Answers: - * - `302`: `Location` is `redirect_uri` with a `token` query parameter appended - * (the single-use token, valid for one minute), sent with + * Answers, every `400` decided before any session is read: + * - `302` with a token: `Location` is `redirect_uri` with a `token` query parameter + * appended (the single-use token, valid for one minute), sent with * `Cache-Control: no-store`. - * - `400 VALIDATION_ERROR`, with NO redirect: an unknown or repeated parameter, - * a `redirect_uri` whose scheme is not registered in + * - `302` to sign in: a page navigation with no session, or with one older than + * five minutes, when this process answers the Console's sign-in page with + * `200 text/html`, unless it reports `Sec-Fetch-Site: same-origin`. `Location` is + * that page, root-relative so the browser stays on this origin, with `redirect` + * naming this same request and `handoff_refused_at` carrying the server's time of + * the refusal in epoch milliseconds. The page's route is not part of this contract: + * an app opens this route and never names it. + * - `400 VALIDATION_ERROR`, with NO redirect: a navigation another site started + * (`Sec-Fetch-Site: cross-site`), an unknown or repeated parameter, a + * `redirect_uri` whose scheme is not registered in * `AuthConfig.trustedAppSchemes` (compared case-insensitively), a malformed * `code_challenge`, or a `code_challenge_method` other than `S256`. - * - `401 UNAUTHENTICATED`: no signed-in session. + * - `401 UNAUTHENTICATED`: no session, or one older than five minutes, on any request + * that does not get that redirect. That is a request that is not a page navigation + * (an app's anonymous probe gets this, and reads it as "this server registered + * me"); a page navigation reporting `Sec-Fetch-Site: same-origin`, which is the + * sign-in page forwarding a session this route refuses; and any request when this + * process does not answer that page with `200 text/html`, so a Console served by + * something in front of this process keeps the `401` too. * * The parameter names are the OAuth 2.0 / RFC 7636 wire names, kept verbatim. */ diff --git a/ui/apps/console/src/pages/auth/LoginPage.tsx b/ui/apps/console/src/pages/auth/LoginPage.tsx index 5339378f123..e26eaa5a9ee 100644 --- a/ui/apps/console/src/pages/auth/LoginPage.tsx +++ b/ui/apps/console/src/pages/auth/LoginPage.tsx @@ -74,7 +74,7 @@ import { import { AuthLayout } from './AuthLayout'; import { AuthFooter, readLegalLinks } from './AuthFooter'; import { followOauthAuthorize } from './followAuthorize'; -import { useHandoffRedirect } from './handoffRedirect'; +import { handoffMarkQuery, useHandoffRedirect } from './handoffRedirect'; // Was module-private here; lifted to a shared module so `SetupPage` (whose // first-run exits went without it) and `RegisterPage` (which had copied it) // share ONE implementation — objectui#4181. Behaviour here is unchanged. @@ -324,9 +324,9 @@ export function LoginPage() { } // Build search-aware sign-up URL so the redirect param survives the - // bounce from /login → /register → /login. + // bounce from /login → /register → /login, and the hand-off route's mark with it. const registerUrl = redirect - ? `/register?redirect=${encodeURIComponent(redirect)}` + ? `/register?redirect=${encodeURIComponent(redirect)}${handoffMarkQuery(redirect)}` : '/register'; const legalLinks = readLegalLinks(authConfig); const offerSignUp = configRead === 'known' && signUpOffer === 'form'; diff --git a/ui/apps/console/src/pages/auth/RegisterPage.tsx b/ui/apps/console/src/pages/auth/RegisterPage.tsx index add7da81e90..54f37041887 100644 --- a/ui/apps/console/src/pages/auth/RegisterPage.tsx +++ b/ui/apps/console/src/pages/auth/RegisterPage.tsx @@ -54,7 +54,7 @@ import { } from '@object-ui/app-shell'; import { AuthLayout } from './AuthLayout'; import { followOauthAuthorize } from './followAuthorize'; -import { useHandoffRedirect } from './handoffRedirect'; +import { handoffMarkQuery, useHandoffRedirect } from './handoffRedirect'; // Was a second module-private copy of LoginPage's helper; both now share one // implementation — objectui#4181. Behaviour here is unchanged. import { withConsoleBase, withConsoleBaseRootRelative } from '../../utils/consoleBase'; @@ -168,7 +168,7 @@ export function RegisterPage() { // Sign-up switched off — bounce to /login. useEffect(() => { if (signUpOffer !== 'closed') return; - const search = redirect ? `?redirect=${encodeURIComponent(redirect)}` : ''; + const search = redirect ? `?redirect=${encodeURIComponent(redirect)}${handoffMarkQuery(redirect)}` : ''; navigate(`/login${search}`, { replace: true }); }, [signUpOffer, navigate, redirect]); @@ -257,7 +257,7 @@ export function RegisterPage() { ); } - const loginUrl = redirect ? `/login?redirect=${encodeURIComponent(redirect)}` : '/login'; + const loginUrl = redirect ? `/login?redirect=${encodeURIComponent(redirect)}${handoffMarkQuery(redirect)}` : '/login'; const legalLinks = readLegalLinks(authConfig); // objectui#10893 — when the server gates sign-in on email verification, the // mail's link is the only road back, so it must carry `?redirect=` (e.g. the diff --git a/ui/apps/console/src/pages/auth/__tests__/handoffRedirect-29971.test.ts b/ui/apps/console/src/pages/auth/__tests__/handoffRedirect-29971.test.ts index 4863d56503d..b757de232d9 100644 --- a/ui/apps/console/src/pages/auth/__tests__/handoffRedirect-29971.test.ts +++ b/ui/apps/console/src/pages/auth/__tests__/handoffRedirect-29971.test.ts @@ -7,8 +7,11 @@ import { describe, it, expect } from 'vitest'; import { AuthEndpointPaths } from '@objectstack/spec/api'; import { + HANDOFF_REFUSED_AT_PARAM, HANDOFF_SESSION_MAX_AGE_MS, + handoffMarkQuery, handoffNavigationTarget, + handoffRefusedAt, isHandoffSessionFresh, sessionCreatedAtMs, } from '../handoffRedirect'; @@ -135,3 +138,90 @@ describe('isHandoffSessionFresh — the five-minute window', () => { expect(isHandoffSessionFresh(found, { arrival: { session: found }, now })).toBe(false); }); }); + +describe('isHandoffSessionFresh — the route said when it refused', () => { + const refusedAt = Date.parse('2026-11-03T12:00:00Z'); + const at = (offsetMs: number) => new Date(refusedAt + offsetMs); + const arrival = { session: null }; + // Where the device's clock might read, against the server's. + const deviceClocks = [ + ['agrees with the server', refusedAt], + ['is an hour behind', refusedAt - 3_600_000], + ['is an hour ahead', refusedAt + 3_600_000], + ] as const; + + it.each(deviceClocks)('a session created after the refusal is fresh when the device clock %s', (_label, now) => { + const session = { createdAt: at(1) }; + expect(isHandoffSessionFresh(session, { arrival: { session }, now, refusedAt })).toBe(true); + }); + + it.each(deviceClocks)('a session not created after it is held when the device clock %s', (_label, now) => { + for (const session of [{ createdAt: at(0) }, { createdAt: at(-1) }, { createdAt: at(-7 * 24 * 3_600_000) }]) { + expect(isHandoffSessionFresh(session, { arrival: { session }, now, refusedAt })).toBe(false); + } + }); + + it('CONTROL: a device behind the server reads that same old session as young when the route said nothing', () => { + const session = { createdAt: at(-6 * 60_000) }; + expect(isHandoffSessionFresh(session, { arrival, now: refusedAt - 3_600_000 })).toBe(true); + expect(isHandoffSessionFresh(session, { arrival, now: refusedAt - 3_600_000, refusedAt })).toBe(false); + }); + + it('reads createdAt as an ISO string too', () => { + const later = { createdAt: at(1).toISOString() }; + const earlier = { createdAt: at(-1).toISOString() }; + expect(isHandoffSessionFresh(later, { arrival, now: refusedAt, refusedAt })).toBe(true); + expect(isHandoffSessionFresh(earlier, { arrival, now: refusedAt, refusedAt })).toBe(false); + }); + + it('a session with no createdAt keeps its own branch: found on arrival is held, made on this page is fresh', () => { + const found = { token: 't0' }; + expect(isHandoffSessionFresh(found, { arrival: { session: found }, now: refusedAt, refusedAt })).toBe(false); + expect(isHandoffSessionFresh(found, { arrival: null, now: refusedAt, refusedAt })).toBe(false); + expect(isHandoffSessionFresh(undefined, { arrival: { session: null }, now: refusedAt, refusedAt })).toBe(true); + expect(isHandoffSessionFresh({ token: 't1' }, { arrival: { session: found }, now: refusedAt, refusedAt })).toBe(true); + const unreadable = { createdAt: 'not a date' }; + expect(isHandoffSessionFresh(unreadable, { arrival: { session: unreadable }, now: refusedAt, refusedAt })).toBe(false); + }); +}); + +describe('handoffMarkQuery — the mark on a link between the sign-in pages', () => { + const marked = `?redirect=x&${HANDOFF_REFUSED_AT_PARAM}=1793620800000`; + + it('is the mark, beside a redirect naming the hand-off', () => { + expect(handoffMarkQuery(`${HANDOFF_PATH}${QUERY}`, marked)).toBe(`&${HANDOFF_REFUSED_AT_PARAM}=1793620800000`); + }); + + it('is empty beside any other redirect, or none, so those links stay what they were', () => { + for (const other of ['/settings', '/api/v1/data/x', `${HANDOFF_PATH}X`, 'https://evil.example/x', '', null]) { + expect(handoffMarkQuery(other, marked), String(other)).toBe(''); + } + }); + + it('is empty when the page carries no mark, or one that is not a number of milliseconds', () => { + expect(handoffMarkQuery(`${HANDOFF_PATH}${QUERY}`, '?redirect=x')).toBe(''); + expect(handoffMarkQuery(`${HANDOFF_PATH}${QUERY}`, '')).toBe(''); + expect(handoffMarkQuery(`${HANDOFF_PATH}${QUERY}`, `?${HANDOFF_REFUSED_AT_PARAM}=soon`)).toBe(''); + }); +}); + +describe('handoffRefusedAt — what the route wrote, or nothing', () => { + const query = (value: string) => `?redirect=%2Fx&${HANDOFF_REFUSED_AT_PARAM}=${value}`; + + it('reads a whole number of milliseconds', () => { + expect(handoffRefusedAt(query('1793620800000'))).toBe(1793620800000); + expect(handoffRefusedAt(`?${HANDOFF_REFUSED_AT_PARAM}=0`)).toBe(0); + }); + + it('is null when the page was not sent here by the route', () => { + expect(handoffRefusedAt('')).toBeNull(); + expect(handoffRefusedAt('?redirect=%2Fx')).toBeNull(); + }); + + it.each(['', 'abc', '12a', '-1', '1.5', '1e3', '%201', '0x10', 'NaN', 'Infinity', '9'.repeat(400)])( + 'ignores %j, so the five-minute check decides', + (value) => { + expect(handoffRefusedAt(query(value))).toBeNull(); + }, + ); +}); diff --git a/ui/apps/console/src/pages/auth/__tests__/signInHandoff-29971.test.tsx b/ui/apps/console/src/pages/auth/__tests__/signInHandoff-29971.test.tsx index 455d6919ebb..64715a61341 100644 --- a/ui/apps/console/src/pages/auth/__tests__/signInHandoff-29971.test.tsx +++ b/ui/apps/console/src/pages/auth/__tests__/signInHandoff-29971.test.tsx @@ -26,7 +26,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, cleanup, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { MemoryRouter } from 'react-router-dom'; +import { MemoryRouter, useLocation } from 'react-router-dom'; import { AuthProvider } from '@object-ui/auth'; import type { AuthClient } from '@object-ui/auth'; import { LoginPage } from '../LoginPage'; @@ -91,13 +91,20 @@ function createMockClient({ arrival = null, config = {}, organizations }: Client return client as typeof client & AuthClient; } -function renderPage(page: 'login' | 'register', client: AuthClient, redirect?: string) { - const search = redirect ? `?redirect=${encodeURIComponent(redirect)}` : ''; +/** Where the router is, for the navigations a page makes without leaving the document. */ +function RouterLocation() { + const location = useLocation(); + return {`${location.pathname}${location.search}`}; +} + +function renderPage(page: 'login' | 'register', client: AuthClient, redirect?: string, extraQuery = '') { + const search = (redirect ? `?redirect=${encodeURIComponent(redirect)}` : '') + extraQuery; window.history.replaceState({}, '', `/_console/${page}${search}`); return render( {page === 'login' ? : } + , ); @@ -236,6 +243,132 @@ describe('LoginPage — rule 4: a session older than five minutes', () => { }); }); +describe('LoginPage — rule 5: the route said when it refused', () => { + const refusedAt = (msFromNow: number) => `&handoff_refused_at=${Date.now() + msFromNow}`; + + it('holds the form for a session not created after the refusal, though this device reads it as young', async () => { + renderPage('login', createMockClient({ arrival: { createdAt: minutesAgo(1) } }), HANDOFF, refusedAt(0)); + expect(await screen.findByLabelText('Email')).toBeTruthy(); + expect(assign).not.toHaveBeenCalled(); + }); + + it('a sign-in on that form forwards to the hand-off', async () => { + renderPage('login', createMockClient({ arrival: { createdAt: minutesAgo(1) } }), HANDOFF, refusedAt(0)); + await signInOnTheForm(); + expect(await exitTarget()).toBe(HANDOFF); + }); + + it('a session created after it forwards, though this device reads it as too old: a provider returned to this URL', async () => { + renderPage('login', createMockClient({ arrival: { createdAt: minutesAgo(10) } }), HANDOFF, refusedAt(-20 * 60_000)); + expect(await exitTarget()).toBe(HANDOFF); + }); + + it('CONTROL: a value that is not digits is ignored, and the five-minute check decides', async () => { + renderPage('login', createMockClient({ arrival: { createdAt: minutesAgo(1) } }), HANDOFF, '&handoff_refused_at=soon'); + expect(await exitTarget()).toBe(HANDOFF); + }); + + it('CONTROL: signed out on arrival, a sign-in forwards as it always did', async () => { + renderPage('login', createMockClient(), HANDOFF, refusedAt(0)); + await signInOnTheForm(); + expect(await exitTarget()).toBe(HANDOFF); + }); +}); + +describe('the mark on the links between /login and /register', () => { + const MARK = '&handoff_refused_at=1793620800000'; + const withRedirect = (page: string, redirect: string) => `/${page}?redirect=${encodeURIComponent(redirect)}`; + const hrefOf = async (name: string) => (await screen.findByRole('link', { name })).getAttribute('href'); + + it('/login carries it on "Sign up", beside a hand-off redirect', async () => { + renderPage('login', createMockClient(), HANDOFF, MARK); + expect(await hrefOf('Sign up')).toBe(`${withRedirect('register', HANDOFF)}${MARK}`); + }); + + it('/register carries it on "Sign in", beside a hand-off redirect', async () => { + renderPage('register', createMockClient(), HANDOFF, MARK); + expect(await hrefOf('Sign in')).toBe(`${withRedirect('login', HANDOFF)}${MARK}`); + }); + + it('/register carries it when a closed sign-up bounces to /login', async () => { + renderPage( + 'register', + createMockClient({ config: { emailPassword: { enabled: true, disableSignUp: true } } }), + HANDOFF, + MARK, + ); + await waitFor(() => + expect(screen.getByTestId('router-location').textContent).toBe(`${withRedirect('login', HANDOFF)}${MARK}`), + ); + }); + + it('CONTROL: beside any other redirect the links are what they were, mark or no mark', async () => { + renderPage('login', createMockClient(), '/settings', MARK); + expect(await hrefOf('Sign up')).toBe(withRedirect('register', '/settings')); + cleanup(); + renderPage('register', createMockClient(), '/settings', MARK); + expect(await hrefOf('Sign in')).toBe(withRedirect('login', '/settings')); + cleanup(); + renderPage( + 'register', + createMockClient({ config: { emailPassword: { enabled: true, disableSignUp: true } } }), + '/settings', + MARK, + ); + await waitFor(() => + expect(screen.getByTestId('router-location').textContent).toBe(withRedirect('login', '/settings')), + ); + }); + + it('CONTROL: beside a hand-off redirect with no mark on the page, the links are what they were', async () => { + renderPage('login', createMockClient(), HANDOFF); + expect(await hrefOf('Sign up')).toBe(withRedirect('register', HANDOFF)); + cleanup(); + renderPage('register', createMockClient(), HANDOFF); + expect(await hrefOf('Sign in')).toBe(withRedirect('login', HANDOFF)); + }); + + it('CONTROL: with no redirect the links are the bare pages', async () => { + renderPage('login', createMockClient()); + expect(await hrefOf('Sign up')).toBe('/register'); + cleanup(); + renderPage('register', createMockClient()); + expect(await hrefOf('Sign in')).toBe('/login'); + }); +}); + +describe('RegisterPage — rule 5: the mark it was linked with', () => { + const refusedAt = (msFromNow: number) => `&handoff_refused_at=${Date.now() + msFromNow}`; + + async function signUpOnTheForm() { + const user = userEvent.setup(); + await user.type(await screen.findByLabelText('Name'), USER.name); + await user.type(screen.getByLabelText('Email'), USER.email); + await user.type(screen.getByLabelText('Password'), 'hunter2hunter2'); + await user.type(screen.getByLabelText('Confirm Password'), 'hunter2hunter2'); + await user.click(screen.getByRole('button', { name: 'Create Account' })); + } + + it('holds the form for a session not created after the refusal, though this device reads it as young', async () => { + renderPage('register', createMockClient({ arrival: { createdAt: minutesAgo(1) } }), HANDOFF, refusedAt(0)); + expect(await screen.findByLabelText('Name')).toBeTruthy(); + expect(assign).not.toHaveBeenCalled(); + }); + + it('a sign-up on that form forwards to the hand-off', async () => { + const client = createMockClient({ arrival: { createdAt: minutesAgo(1) } }); + renderPage('register', client, HANDOFF, refusedAt(0)); + await signUpOnTheForm(); + await waitFor(() => expect(client.signUp).toHaveBeenCalledTimes(1)); + expect(await exitTarget()).toBe(HANDOFF); + }); + + it('a session created after it forwards, though this device reads it as too old: a provider returned to this URL', async () => { + renderPage('register', createMockClient({ arrival: { createdAt: minutesAgo(10) } }), HANDOFF, refusedAt(-20 * 60_000)); + expect(await exitTarget()).toBe(HANDOFF); + }); +}); + describe('RegisterPage — the same hand-off after a sign-up', () => { it('navigates to the hand-off unprefixed for a fresh session', async () => { renderPage('register', createMockClient({ arrival: { createdAt: minutesAgo(1) } }), HANDOFF); diff --git a/ui/apps/console/src/pages/auth/handoffRedirect.ts b/ui/apps/console/src/pages/auth/handoffRedirect.ts index b36e888d61d..e9857ebf679 100644 --- a/ui/apps/console/src/pages/auth/handoffRedirect.ts +++ b/ui/apps/console/src/pages/auth/handoffRedirect.ts @@ -1,13 +1,14 @@ /** * The mobile sign-in hand-off as a sign-in redirect target (#29971). * - * The mobile shell opens this Console's sign-in page in the platform's system - * auth session with `?redirect=` naming the hand-off route - * (`GET {auth base}/one-time-token/handoff?redirect_uri=…&code_challenge=…`), - * which answers `302 SCHEME://…?token=T` for the signed-in session. That route - * is an API route OUTSIDE the Console mount, so the ordinary post-sign-in - * navigation (`withConsoleBase`, which treats every redirect as a Console - * route) lands on a Console page that does not exist. + * The mobile shell opens the hand-off route + * (`GET {auth base}/one-time-token/handoff?redirect_uri=…&code_challenge=…`) in + * the platform's system auth session, which answers `302 SCHEME://…?token=T` for + * a signed-in session and sends a browser that has to sign in here, with + * `?redirect=` naming that same request. That route is an API route OUTSIDE the + * Console mount, so the ordinary post-sign-in navigation (`withConsoleBase`, + * which treats every redirect as a Console route) lands on a Console page that + * does not exist. * * The route's own rules (plugin-auth `one-time-token-handoff.ts`, maintainer * ruling A on the server card) decide what the sign-in pages do with it: @@ -23,11 +24,19 @@ * (`same-origin`). * 3. **Before the organization steps.** The hand-off carries the session and * nothing else; the app's WebView picks the organization after it. - * 4. **Only a fresh session.** The route refuses a session created more than - * five minutes ago with a raw JSON `401`; on a hand-off redirect the page - * shows its sign-in form for such a session instead of forwarding, and a - * sign-in on that form makes a new session on top of the old one - * ({@link useHandoffRedirect}). + * 4. **Only a fresh session.** The route hands off only a session created + * within five minutes. On a hand-off redirect the page shows its sign-in form + * for an older one instead of forwarding, and a sign-in on that form makes a + * new session on top of the old one ({@link useHandoffRedirect}). + * 5. **The route's clock over the device's.** The page reads a session's age on + * the device's clock and the route on the server's, so a device whose clock + * is behind would forward a session the route refuses. When the route sent + * the browser here, its redirect says when it refused + * ({@link HANDOFF_REFUSED_AT_PARAM}), and the page holds the form for any + * session not created after that, whatever the device's clock says + * ({@link isHandoffSessionFresh}). The links between `/login` and `/register` + * carry it beside a hand-off `redirect` ({@link handoffMarkQuery}), so following + * "Sign up" does not put the device's clock back in charge. */ import { useLayoutEffect, useState } from 'react'; @@ -44,6 +53,15 @@ const AUTH_BASE = `${import.meta.env.VITE_SERVER_URL || ''}/api/v1/auth`; */ export const HANDOFF_SESSION_MAX_AGE_MS = 5 * 60 * 1000; +/** + * On the route's redirect to this page, beside `redirect`: when it refused the + * browser's session, in epoch milliseconds on the server's clock. The hand-off + * route's `HANDOFF_REFUSED_AT_PARAM` (plugin-auth), restated because that module + * is server-only; `examples/app-showcase/e2e/mobile-signin-handoff.spec.ts` is + * what notices the two disagreeing. + */ +export const HANDOFF_REFUSED_AT_PARAM = 'handoff_refused_at'; + /** * The navigation target for a hand-off redirect, or `null` when `redirect` is * anything else (which then stays a Console route). @@ -90,21 +108,59 @@ export function sessionCreatedAtMs(session: unknown): number | null { return Number.isNaN(ms) ? null : ms; } +/** + * When the route refused this browser's session, from the page's query string; + * `null` when the page was not sent here by the route, or the value is not a + * whole number of milliseconds. + */ +export function handoffRefusedAt(search: string): number | null { + const raw = new URLSearchParams(search).get(HANDOFF_REFUSED_AT_PARAM); + if (raw === null || !/^\d+$/.test(raw)) return null; + const refusedAt = Number(raw); + return Number.isSafeInteger(refusedAt) ? refusedAt : null; +} + +/** + * The route's mark as a query suffix, for a link between the sign-in pages: empty + * unless `redirect` names the hand-off and this page carries a mark, so every other + * link stays exactly what it was. + */ +export function handoffMarkQuery( + redirect: string | null, + search: string = typeof window === 'undefined' ? '' : window.location.search, +): string { + if (handoffNavigationTarget(redirect) === null) return ''; + const refusedAt = handoffRefusedAt(search); + return refusedAt === null ? '' : `&${HANDOFF_REFUSED_AT_PARAM}=${refusedAt}`; +} + /** * Whether the hand-off will carry `session`. * - * With a `createdAt`, its age decides. Without one, the session is fresh only - * when it is not the one the page found on arrival: it came from a sign-in on - * this page, which the server has just made. A session found on arrival with - * no `createdAt` is treated as too old, so the visitor signs in again rather - * than being forwarded to a refusal. + * With a `createdAt` and the route's `refusedAt`, the session is fresh only if it + * was created after the refusal: two readings of the server's clock, so the + * device's is not read, and a provider's sign-in that returns to this same URL + * counts as new. On several nodes behind one host those are two clocks, so skew can + * hold the form until a sign-in makes a later `createdAt`, which clears it. With a + * `createdAt` alone, its age decides. Without one, the + * session is fresh only when it is not the one the page found on arrival: it came + * from a sign-in on this page, which the server has just made. A session found on + * arrival with no `createdAt` is treated as too old, so the visitor signs in again + * rather than being forwarded to a refusal. */ export function isHandoffSessionFresh( session: unknown, - { arrival, now }: { arrival: { session: unknown } | null; now: number }, + { + arrival, + now, + refusedAt = null, + }: { arrival: { session: unknown } | null; now: number; refusedAt?: number | null }, ): boolean { const createdAt = sessionCreatedAtMs(session); - if (createdAt !== null) return now - createdAt <= HANDOFF_SESSION_MAX_AGE_MS; + if (createdAt !== null) { + if (refusedAt !== null) return createdAt > refusedAt; + return now - createdAt <= HANDOFF_SESSION_MAX_AGE_MS; + } return arrival !== null && session !== arrival.session; } @@ -149,7 +205,15 @@ export function useHandoffRedirect( if (verdict === null && isLoading) return; if (verdict !== null && verdict.session === session) return; const arrival = verdict?.arrival ?? { session }; - setVerdict({ session, arrival, fresh: isHandoffSessionFresh(session, { arrival, now: Date.now() }) }); + setVerdict({ + session, + arrival, + fresh: isHandoffSessionFresh(session, { + arrival, + now: Date.now(), + refusedAt: handoffRefusedAt(window.location.search), + }), + }); }, [target, isLoading, session, verdict]); const judgedFresh = verdict !== null && verdict.session === session && verdict.fresh;