From 42d92ddf26fd49bb8d8cde6fbb25e32a9a21ff4d Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:39:08 +0800 Subject: [PATCH 1/7] feat(plugin-auth): a page navigation to the mobile hand-off that has to sign in first goes to the sign-in page The app can now open the hand-off and nothing else, so the Console's sign-in route stays internal to this repository and its release. Every 400 stays ahead of the new answer, a non-navigation keeps the 401 the app's probe reads, a deployment with no Console keeps it too, and a same-origin navigation the route still refuses gets the 401 instead of another redirect. Claude-Session: https://claude.ai/code/session_c967d7a472b0485c94711bfcb22159c8 Co-authored-by: Claude --- .changeset/29997-handoff-signin-entry.md | 18 + .../plugins/plugin-auth/src/auth-plugin.ts | 29 +- .../plugin-auth/src/auth-route-ledger.ts | 2 +- .../src/one-time-token-handoff.test.ts | 337 +++++++++++++++++- .../plugin-auth/src/one-time-token-handoff.ts | 228 +++++++++++- packages/spec/src/api/auth-endpoints.zod.ts | 37 +- 6 files changed, 607 insertions(+), 44 deletions(-) create mode 100644 .changeset/29997-handoff-signin-entry.md diff --git a/.changeset/29997-handoff-signin-entry.md b/.changeset/29997-handoff-signin-entry.md new file mode 100644 index 00000000000..16b0fbfc4ac --- /dev/null +++ b/.changeset/29997-handoff-signin-entry.md @@ -0,0 +1,18 @@ +--- +'@objectstack/plugin-auth': patch +'@objectstack/spec': 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 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. +- The redirect cannot bounce. It carries `handoff_refused_at`, the server's time of the refusal, for the sign-in page to judge a session by, and a same-origin navigation this route still refuses gets the `401`. + +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/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index de18624bd66..156e4d81e0b 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -81,7 +81,7 @@ import { runSetInitialPassword } from './set-initial-password.js'; import { runRegisterSsoProviderFromForm, runRegisterSamlProviderFromForm, runRequestDomainVerification, runVerifyDomain, type AuthRequestHandler } from './register-sso-provider.js'; import { runResendVerificationEmail } from './send-verification-email.js'; import { runSysSessionRevoke, sysSessionRevokeDepsFor } from './sys-session-revoke.js'; -import { runOneTimeTokenHandoff } from './one-time-token-handoff.js'; +import { resolveHandoffSignInEntry, runOneTimeTokenHandoff } from './one-time-token-handoff.js'; import { readJsonRequestBody } from './request-json-body.js'; import { seedCreatedOrganizationCurrency, @@ -4020,10 +4020,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!; @@ -4040,6 +4043,24 @@ 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'), + dispatch: (request) => rawApp.fetch(request, 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..6588765eaeb 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) and any other caller 401, as does a deployment that serves no sign-in page. 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..7a8294243b6 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, 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,304 @@ 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 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 = (dispatch: (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', + dispatch, + 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'); + }); +}); + 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 +643,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..0857d4c52cc 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,50 @@ * (`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` above 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, each read off a failure: + * + * - **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`. + * - **No Console, no redirect.** A sign-in page is sent to only when this + * process answers it 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`, and the first one says so once at + * `warn`. + * - **It cannot bounce.** The sign-in page judges a session's age on the + * device's clock and this route judges it on the server's, so on a device + * whose clock is behind, the page forwards a session this route refuses. + * Two things stop that: the redirect carries {@link HANDOFF_REFUSED_AT_PARAM}, + * the server's time of the refusal, which lets the page hold its form for + * any session created by then without reading the device's clock; and 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 second holds even for a page that ignores the first. + * * ## The registered app-scheme list * * `redirect_uri` must name a scheme the deployment registered in @@ -125,6 +170,19 @@ 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. + * + * 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. The comparison is between + * two readings of one clock. 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 +312,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 +333,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 +361,112 @@ 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. + */ +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, answering in-process. */ + dispatch(request: Request): Response | Promise; + /** 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 headers = new Headers({ accept: 'text/html' }); + if (source.requestHost) headers.set('host', source.requestHost); + const answer = await source.dispatch(new Request(new URL(localPath, source.requestUrl), { headers })); + const served = answer.status === 200 && (answer.headers.get('content-type') ?? '').toLowerCase().startsWith('text/html'); + const reason = `it answers ${answer.status}`; + try { + await answer.body?.cancel(); + } catch { + // The body is never read; a stream that cannot be cancelled changes nothing. + } + if (!served) { + source.onUnserved(localPath, reason); + return null; + } + return { signInPage, handoff: `${authBase}${ONE_TIME_TOKEN_HANDOFF_PATH}` }; +} + +/** + * 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. Sending it back + // would bounce forever; 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 +474,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 +522,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..6ddcc4a9db1 100644 --- a/packages/spec/src/api/auth-endpoints.zod.ts +++ b/packages/spec/src/api/auth-endpoints.zod.ts @@ -415,25 +415,34 @@ 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, on a deployment that serves the Console. `Location` is the + * Console's sign-in page, root-relative so the browser stays on this origin, with + * `redirect` naming this same request. 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 a request + * that is not such a page navigation. An app's anonymous probe gets this, and reads + * it as "this server registered me". So does a deployment that serves no Console. * * The parameter names are the OAuth 2.0 / RFC 7636 wire names, kept verbatim. */ From ed33ec68c730288f6c4653bcc0ad84522d8210bf Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:40:53 +0800 Subject: [PATCH 2/7] test(showcase): the mobile sign-in end to end in a real browser against the real Console Claude-Session: https://claude.ai/code/session_c967d7a472b0485c94711bfcb22159c8 Co-authored-by: Claude --- .../e2e/mobile-handoff-server.config.ts | 32 +++++++ .../e2e/mobile-signin-handoff.spec.ts | 85 +++++++++++++++++++ examples/app-showcase/playwright.config.ts | 8 +- 3 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 examples/app-showcase/e2e/mobile-handoff-server.config.ts create mode 100644 examples/app-showcase/e2e/mobile-signin-handoff.spec.ts diff --git a/examples/app-showcase/e2e/mobile-handoff-server.config.ts b/examples/app-showcase/e2e/mobile-handoff-server.config.ts new file mode 100644 index 00000000000..6015e082cb5 --- /dev/null +++ b/examples/app-showcase/e2e/mobile-handoff-server.config.ts @@ -0,0 +1,32 @@ +import showcase from '../objectstack.config.js'; + +/** + * What `playwright.config.ts` boots: the showcase, with one app scheme registered + * so `mobile-signin-handoff.spec.ts` has somewhere to hand a token. + * + * A deployment registers its app schemes in `AuthConfig.trustedAppSchemes`, and + * `os serve` composes its `AuthPlugin` with no way to set that key from outside + * yet, so this plugin patches the running manager: the same config the route and + * the `one-time-token` plugin read, which is rebuilt on the next request. Once + * `os serve` reads the list from its environment (`OS_TRUSTED_APP_SCHEMES`), this + * file goes and `playwright.config.ts` sets that variable on its `webServer`. + * + * Mirrors `HANDOFF_APP_SCHEME` in the spec, which imports nothing from here because + * this module pulls in the whole stack. + */ +const HANDOFF_APP_SCHEME = 'com.example.showcase.e2e'; + +const RegisterHandoffAppScheme = { + name: 'com.example.showcase.e2e.handoff-app-scheme', + version: '1.0.0', + init: async (ctx: any) => { + ctx.hook('kernel:ready', async () => { + ctx.getService('auth').applyConfigPatch({ trustedAppSchemes: [HANDOFF_APP_SCHEME] }); + }); + }, +}; + +export default { + ...showcase, + plugins: [...((showcase as { plugins?: unknown[] }).plugins ?? []), RegisterHandoffAppScheme], +}; 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..6032698f8d5 --- /dev/null +++ b/examples/app-showcase/e2e/mobile-signin-handoff.spec.ts @@ -0,0 +1,85 @@ +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. + * + * Reached with `reuseExistingServer` on a server that did not boot + * `e2e/mobile-handoff-server.config.ts`, 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 `mobile-handoff-server.config.ts` registers. */ +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); +}); diff --git a/examples/app-showcase/playwright.config.ts b/examples/app-showcase/playwright.config.ts index 8166780ab65..ff8a511edd0 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 boots + * + * `e2e/mobile-handoff-server.config.ts`: this app, plus the one app scheme + * `e2e/mobile-signin-handoff.spec.ts` hands a token to. Every other spec sees the + * showcase unchanged. */ const BASE_URL = new URL(process.env.SMOKE_API_URL || 'http://localhost:3000'); const PORT = BASE_URL.port || '3000'; @@ -57,7 +63,7 @@ export default defineConfig({ }, }], webServer: { - command: `node node_modules/@objectstack/cli/bin/run.js serve --dev -p ${PORT}`, + command: `node node_modules/@objectstack/cli/bin/run.js serve e2e/mobile-handoff-server.config.ts --dev -p ${PORT}`, url: `${BASE_URL.origin}/api/v1/runtime/config`, timeout: 180_000, reuseExistingServer: !process.env.CI, From 0fef687f871a8311ca3a5136af0b66a51453d2ee Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:43:58 +0800 Subject: [PATCH 3/7] fix(plugin-auth): a sign-in page probe that throws keeps the 401 instead of failing the hand-off Claude-Session: https://claude.ai/code/session_c967d7a472b0485c94711bfcb22159c8 Co-authored-by: Claude --- .../plugin-auth/src/one-time-token-handoff.test.ts | 8 ++++++++ .../plugins/plugin-auth/src/one-time-token-handoff.ts | 11 ++++++++--- 2 files changed, 16 insertions(+), 3 deletions(-) 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 7a8294243b6..b39330b1538 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 @@ -619,6 +619,14 @@ describe('resolveHandoffSignInEntry', () => { expect(await resolveHandoffSignInEntry(inputs)).toBeNull(); expect(inputs.onUnserved).toHaveBeenCalledWith('/_console/login', 'it answers 404'); }); + + 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', () => { 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 0857d4c52cc..e0872e41f13 100644 --- a/packages/plugins/plugin-auth/src/one-time-token-handoff.ts +++ b/packages/plugins/plugin-auth/src/one-time-token-handoff.ts @@ -427,16 +427,21 @@ export async function resolveHandoffSignInEntry(source: { const headers = new Headers({ accept: 'text/html' }); if (source.requestHost) headers.set('host', source.requestHost); - const answer = await source.dispatch(new Request(new URL(localPath, source.requestUrl), { headers })); + let answer: Response; + try { + answer = await source.dispatch(new Request(new URL(localPath, source.requestUrl), { headers })); + } 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'); - const reason = `it answers ${answer.status}`; try { await answer.body?.cancel(); } catch { // The body is never read; a stream that cannot be cancelled changes nothing. } if (!served) { - source.onUnserved(localPath, reason); + source.onUnserved(localPath, `it answers ${answer.status}`); return null; } return { signInPage, handoff: `${authBase}${ONE_TIME_TOKEN_HANDOFF_PATH}` }; From 6c3eda35ef2bb7f34234f2e298404469b77029e2 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:48:57 +0800 Subject: [PATCH 4/7] test(showcase): the hand-off spec registers its scheme the way a deployment does A test-only stack config has to sit at the app root and go through composeStacks, and then every smoke spec boots a composed stack. OS_TRUSTED_APP_SCHEMES on the webServer is one line and the real path. Claude-Session: https://claude.ai/code/session_c967d7a472b0485c94711bfcb22159c8 Co-authored-by: Claude --- .../e2e/mobile-handoff-server.config.ts | 32 ------------------- .../e2e/mobile-signin-handoff.spec.ts | 8 ++--- examples/app-showcase/playwright.config.ts | 11 ++++--- .../test/showcase-smoke.live.test.ts | 5 +-- .../plugin-auth/src/one-time-token-handoff.ts | 2 +- 5 files changed, 14 insertions(+), 44 deletions(-) delete mode 100644 examples/app-showcase/e2e/mobile-handoff-server.config.ts diff --git a/examples/app-showcase/e2e/mobile-handoff-server.config.ts b/examples/app-showcase/e2e/mobile-handoff-server.config.ts deleted file mode 100644 index 6015e082cb5..00000000000 --- a/examples/app-showcase/e2e/mobile-handoff-server.config.ts +++ /dev/null @@ -1,32 +0,0 @@ -import showcase from '../objectstack.config.js'; - -/** - * What `playwright.config.ts` boots: the showcase, with one app scheme registered - * so `mobile-signin-handoff.spec.ts` has somewhere to hand a token. - * - * A deployment registers its app schemes in `AuthConfig.trustedAppSchemes`, and - * `os serve` composes its `AuthPlugin` with no way to set that key from outside - * yet, so this plugin patches the running manager: the same config the route and - * the `one-time-token` plugin read, which is rebuilt on the next request. Once - * `os serve` reads the list from its environment (`OS_TRUSTED_APP_SCHEMES`), this - * file goes and `playwright.config.ts` sets that variable on its `webServer`. - * - * Mirrors `HANDOFF_APP_SCHEME` in the spec, which imports nothing from here because - * this module pulls in the whole stack. - */ -const HANDOFF_APP_SCHEME = 'com.example.showcase.e2e'; - -const RegisterHandoffAppScheme = { - name: 'com.example.showcase.e2e.handoff-app-scheme', - version: '1.0.0', - init: async (ctx: any) => { - ctx.hook('kernel:ready', async () => { - ctx.getService('auth').applyConfigPatch({ trustedAppSchemes: [HANDOFF_APP_SCHEME] }); - }); - }, -}; - -export default { - ...showcase, - plugins: [...((showcase as { plugins?: unknown[] }).plugins ?? []), RegisterHandoffAppScheme], -}; diff --git a/examples/app-showcase/e2e/mobile-signin-handoff.spec.ts b/examples/app-showcase/e2e/mobile-signin-handoff.spec.ts index 6032698f8d5..3e7b201c54a 100644 --- a/examples/app-showcase/e2e/mobile-signin-handoff.spec.ts +++ b/examples/app-showcase/e2e/mobile-signin-handoff.spec.ts @@ -17,16 +17,16 @@ declare const process: { env: Record }; * 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. * - * Reached with `reuseExistingServer` on a server that did not boot - * `e2e/mobile-handoff-server.config.ts`, the first assertion fails with a `400`: - * no scheme is registered there. + * 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 `mobile-handoff-server.config.ts` registers. */ +/** 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))`. */ diff --git a/examples/app-showcase/playwright.config.ts b/examples/app-showcase/playwright.config.ts index ff8a511edd0..be37cc9c003 100644 --- a/examples/app-showcase/playwright.config.ts +++ b/examples/app-showcase/playwright.config.ts @@ -32,11 +32,11 @@ declare const process: { env: Record }; * resolves, in which case every test dies at launch (RUNNER.md, "Environment * facts"). Unset, Playwright resolves its own browser exactly as before. * - * ## What it boots + * ## What it registers * - * `e2e/mobile-handoff-server.config.ts`: this app, plus the one app scheme - * `e2e/mobile-signin-handoff.spec.ts` hands a token to. Every other spec sees the - * showcase unchanged. + * `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'; @@ -63,7 +63,8 @@ export default defineConfig({ }, }], webServer: { - command: `node node_modules/@objectstack/cli/bin/run.js serve e2e/mobile-handoff-server.config.ts --dev -p ${PORT}`, + 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/one-time-token-handoff.ts b/packages/plugins/plugin-auth/src/one-time-token-handoff.ts index e0872e41f13..62ea7acb18c 100644 --- a/packages/plugins/plugin-auth/src/one-time-token-handoff.ts +++ b/packages/plugins/plugin-auth/src/one-time-token-handoff.ts @@ -81,7 +81,7 @@ * 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` above is decided before either: + * 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 From bf94338fd52308c3897e6ae7d9f47346c9d95338 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Wed, 4 Nov 2026 12:05:43 +0800 Subject: [PATCH 5/7] fix(plugin-auth): the sign-in page probe takes the server as a value, which the mount census can read Claude-Session: https://claude.ai/code/session_c967d7a472b0485c94711bfcb22159c8 Co-authored-by: Claude --- .../plugins/plugin-auth/src/auth-plugin.ts | 3 ++- .../src/one-time-token-handoff.test.ts | 4 ++-- .../plugin-auth/src/one-time-token-handoff.ts | 21 ++++++++++--------- 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index 156e4d81e0b..f66a966a83b 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -4050,7 +4050,8 @@ export class AuthPlugin implements Plugin { authMountPath: basePath, requestUrl: c.req.url, requestHost: headers.get('host'), - dispatch: (request) => rawApp.fetch(request, c.env), + server: rawApp, + serverEnv: c.env, onUnserved: (path, reason) => { if (reportedNoHandoffSignInPage) return; reportedNoHandoffSignInPage = true; 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 b39330b1538..ce6e4af3863 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 @@ -559,13 +559,13 @@ describe('isPageNavigation', () => { }); describe('resolveHandoffSignInEntry', () => { - const source = (dispatch: (request: Request) => Response, overrides: Record = {}) => ({ + 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', - dispatch, + server: { fetch }, onUnserved: vi.fn(), ...overrides, }); 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 62ea7acb18c..6ce55ddf219 100644 --- a/packages/plugins/plugin-auth/src/one-time-token-handoff.ts +++ b/packages/plugins/plugin-auth/src/one-time-token-handoff.ts @@ -96,19 +96,19 @@ * navigation. A request carrying neither gets the `401`, which is what this * route answered before the entry existed. * - * Three more rules, each read off a failure: + * 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`. - * - **No Console, no redirect.** A sign-in page is sent to only when this - * process answers it 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`, and the first one says so once at - * `warn`. + * - **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 cannot bounce.** The sign-in page judges a session's age on the * device's clock and this route judges it on the server's, so on a device * whose clock is behind, the page forwards a session this route refuses. @@ -402,8 +402,9 @@ export async function resolveHandoffSignInEntry(source: { authMountPath: string; requestUrl: string; requestHost: string | null; - /** The composed server, answering in-process. */ - dispatch(request: Request): Response | Promise; + /** 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 { @@ -429,7 +430,7 @@ export async function resolveHandoffSignInEntry(source: { if (source.requestHost) headers.set('host', source.requestHost); let answer: Response; try { - answer = await source.dispatch(new Request(new URL(localPath, source.requestUrl), { headers })); + 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; From 584f1e43fc38715b02a0bec34de48a5f4228c39c Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Wed, 4 Nov 2026 22:01:15 +0800 Subject: [PATCH 6/7] fix(console): the sign-in page believes the hand-off route's refusal time over the device's clock The route's redirect says when it refused the session. A session not created after that keeps the form up whatever the device's clock says, and one created after it forwards, so a device whose clock is behind does not bounce between the route and the page, and a provider's sign-in that returns to the same URL still ends at the app. The docblocks and the changeset say what is true with this in place, and the spec's published answers name the same-origin case. Claude-Session: https://claude.ai/code/session_c967d7a472b0485c94711bfcb22159c8 Co-authored-by: Claude --- .changeset/29997-handoff-signin-entry.md | 5 +- .../e2e/mobile-signin-handoff.spec.ts | 28 ++++++ .../plugin-auth/src/auth-route-ledger.ts | 2 +- .../src/one-time-token-handoff.test.ts | 25 +++++- .../plugin-auth/src/one-time-token-handoff.ts | 41 ++++++--- packages/spec/src/api/auth-endpoints.zod.ts | 18 ++-- .../__tests__/handoffRedirect-29971.test.ts | 69 +++++++++++++++ .../__tests__/signInHandoff-29971.test.tsx | 36 +++++++- .../console/src/pages/auth/handoffRedirect.ts | 86 ++++++++++++++----- 9 files changed, 264 insertions(+), 46 deletions(-) diff --git a/.changeset/29997-handoff-signin-entry.md b/.changeset/29997-handoff-signin-entry.md index 16b0fbfc4ac..61452f01a8a 100644 --- a/.changeset/29997-handoff-signin-entry.md +++ b/.changeset/29997-handoff-signin-entry.md @@ -1,6 +1,7 @@ --- '@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 @@ -11,8 +12,8 @@ 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 deployment that does not serve the Console keeps the `401` too, and says so once at `warn`. +- 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. -- The redirect cannot bounce. It carries `handoff_refused_at`, the server's time of the refusal, for the sign-in page to judge a session by, and a same-origin navigation this route still refuses gets the `401`. +- 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. 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 index 3e7b201c54a..9a9040c7e5e 100644 --- a/examples/app-showcase/e2e/mobile-signin-handoff.spec.ts +++ b/examples/app-showcase/e2e/mobile-signin-handoff.spec.ts @@ -83,3 +83,31 @@ test('a signed-out browser opens the hand-off, signs in on the sign-in page, and 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/packages/plugins/plugin-auth/src/auth-route-ledger.ts b/packages/plugins/plugin-auth/src/auth-route-ledger.ts index 6588765eaeb..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 — 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) and any other caller 401, as does a deployment that serves no sign-in page. 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 ce6e4af3863..30b3cf443f2 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 @@ -135,7 +135,7 @@ const arrange = async ( const redeem = (token: string) => postJson(manager, ONE_TIME_TOKEN_VERIFY_PATH, { token }); - return { engine, manager, ctx, 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`. */ @@ -532,6 +532,29 @@ describe('one-time-token hand-off — a page navigation without a fresh session 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('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); 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 6ce55ddf219..1f6205cbb63 100644 --- a/packages/plugins/plugin-auth/src/one-time-token-handoff.ts +++ b/packages/plugins/plugin-auth/src/one-time-token-handoff.ts @@ -109,15 +109,26 @@ * 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 cannot bounce.** The sign-in page judges a session's age on the - * device's clock and this route judges it on the server's, so on a device - * whose clock is behind, the page forwards a session this route refuses. - * Two things stop that: the redirect carries {@link HANDOFF_REFUSED_AT_PARAM}, - * the server's time of the refusal, which lets the page hold its form for - * any session created by then without reading the device's clock; and 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 second holds even for a page that ignores the first. + * - **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. + * 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 * @@ -172,7 +183,8 @@ 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. + * 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 @@ -391,7 +403,10 @@ export function isPageNavigation(headers: Headers): boolean { * 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. + * 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. */ @@ -461,8 +476,8 @@ async function refusedSessionAnswer( refused: HandoffAnswer, ): Promise { if (!isPageNavigation(request.headers)) return refused; - // The sign-in page forwarded a session this route refuses. Sending it back - // would bounce forever; the shell's own entry reports `none`. + // 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; diff --git a/packages/spec/src/api/auth-endpoints.zod.ts b/packages/spec/src/api/auth-endpoints.zod.ts index 6ddcc4a9db1..687d1e77d25 100644 --- a/packages/spec/src/api/auth-endpoints.zod.ts +++ b/packages/spec/src/api/auth-endpoints.zod.ts @@ -431,18 +431,22 @@ export type DeviceTokenResponse = z.input; * appended (the single-use token, valid for one minute), sent with * `Cache-Control: no-store`. * - `302` to sign in: a page navigation with no session, or with one older than - * five minutes, on a deployment that serves the Console. `Location` is the - * Console's sign-in page, root-relative so the browser stays on this origin, with - * `redirect` naming this same request. The page's route is not part of this - * contract: an app opens this route and never names it. + * five minutes, on a deployment that serves the Console, unless it reports + * `Sec-Fetch-Site: same-origin`. `Location` is the Console's sign-in page, + * root-relative so the browser stays on this origin, with `redirect` naming this + * same request. 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 session, or one older than five minutes, on a request - * that is not such a page navigation. An app's anonymous probe gets this, and reads - * it as "this server registered me". So does a deployment that serves no Console. + * - `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 on a + * deployment that serves no Console. * * The parameter names are the OAuth 2.0 / RFC 7636 wire names, kept verbatim. */ 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..df0a7ed72bb 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,10 @@ import { describe, it, expect } from 'vitest'; import { AuthEndpointPaths } from '@objectstack/spec/api'; import { + HANDOFF_REFUSED_AT_PARAM, HANDOFF_SESSION_MAX_AGE_MS, handoffNavigationTarget, + handoffRefusedAt, isHandoffSessionFresh, sessionCreatedAtMs, } from '../handoffRedirect'; @@ -135,3 +137,70 @@ 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('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..fa98093fd47 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 @@ -91,8 +91,8 @@ 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)}` : ''; +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( @@ -236,6 +236,38 @@ 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('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..c5b1b3bb2bb 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,17 @@ * (`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}). */ import { useLayoutEffect, useState } from 'react'; @@ -44,6 +51,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 +106,43 @@ 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; +} + /** * 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. 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 +187,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; From 3e59db0af0da68805e35387a3ee09e806d0cdd2a Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Thu, 5 Nov 2026 00:12:12 +0800 Subject: [PATCH 7/7] fix(plugin-auth,console): the hand-off's contract review findings The spec docblock lists handoff_refused_at among what the sign-in Location carries, and says what the code tests: this process answers the page with 200 text/html, so a Console in front of it keeps the 401. A sign-in page or hand-off path starting with // counts as unserved, because config alone can compose one and it would make Location protocol-relative. The docblocks say the two readings are two clocks on several nodes. The links between /login and /register carry the mark beside a hand-off redirect, and every other link is unchanged. Claude-Session: https://claude.ai/code/session_c967d7a472b0485c94711bfcb22159c8 Co-authored-by: Claude --- .changeset/29997-handoff-signin-entry.md | 2 +- .../src/one-time-token-handoff.test.ts | 27 +++++ .../plugin-auth/src/one-time-token-handoff.ts | 22 +++- packages/spec/src/api/auth-endpoints.zod.ts | 16 +-- ui/apps/console/src/pages/auth/LoginPage.tsx | 6 +- .../console/src/pages/auth/RegisterPage.tsx | 6 +- .../__tests__/handoffRedirect-29971.test.ts | 21 ++++ .../__tests__/signInHandoff-29971.test.tsx | 103 +++++++++++++++++- .../console/src/pages/auth/handoffRedirect.ts | 22 +++- 9 files changed, 203 insertions(+), 22 deletions(-) diff --git a/.changeset/29997-handoff-signin-entry.md b/.changeset/29997-handoff-signin-entry.md index 61452f01a8a..44f2a33ba85 100644 --- a/.changeset/29997-handoff-signin-entry.md +++ b/.changeset/29997-handoff-signin-entry.md @@ -14,6 +14,6 @@ Metadata-protocol: none - 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. +- 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/packages/plugins/plugin-auth/src/one-time-token-handoff.test.ts b/packages/plugins/plugin-auth/src/one-time-token-handoff.test.ts index 30b3cf443f2..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 @@ -555,6 +555,21 @@ describe('one-time-token hand-off — a page navigation without a fresh session 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); @@ -643,6 +658,18 @@ describe('resolveHandoffSignInEntry', () => { 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'); 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 1f6205cbb63..d404d145a49 100644 --- a/packages/plugins/plugin-auth/src/one-time-token-handoff.ts +++ b/packages/plugins/plugin-auth/src/one-time-token-handoff.ts @@ -102,7 +102,8 @@ * 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`. + * 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 @@ -118,7 +119,9 @@ * 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. + * 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. @@ -189,8 +192,10 @@ export const ONE_TIME_TOKEN_HANDOFF_SESSION_MAX_AGE_SECONDS = 5 * 60; * 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. The comparison is between - * two readings of one clock. The page's verdict is never a security question: + * 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'; @@ -440,6 +445,13 @@ export async function resolveHandoffSignInEntry(source: { 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); @@ -460,7 +472,7 @@ export async function resolveHandoffSignInEntry(source: { source.onUnserved(localPath, `it answers ${answer.status}`); return null; } - return { signInPage, handoff: `${authBase}${ONE_TIME_TOKEN_HANDOFF_PATH}` }; + return { signInPage, handoff }; } /** diff --git a/packages/spec/src/api/auth-endpoints.zod.ts b/packages/spec/src/api/auth-endpoints.zod.ts index 687d1e77d25..f64a881c5db 100644 --- a/packages/spec/src/api/auth-endpoints.zod.ts +++ b/packages/spec/src/api/auth-endpoints.zod.ts @@ -431,11 +431,12 @@ export type DeviceTokenResponse = z.input; * appended (the single-use token, valid for one minute), sent with * `Cache-Control: no-store`. * - `302` to sign in: a page navigation with no session, or with one older than - * five minutes, on a deployment that serves the Console, unless it reports - * `Sec-Fetch-Site: same-origin`. `Location` is the Console's sign-in page, - * root-relative so the browser stays on this origin, with `redirect` naming this - * same request. The page's route is not part of this contract: an app opens this - * route and never names it. + * 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 @@ -445,8 +446,9 @@ export type DeviceTokenResponse = z.input; * 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 on a - * deployment that serves no Console. + * 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 df0a7ed72bb..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 @@ -9,6 +9,7 @@ import { AuthEndpointPaths } from '@objectstack/spec/api'; import { HANDOFF_REFUSED_AT_PARAM, HANDOFF_SESSION_MAX_AGE_MS, + handoffMarkQuery, handoffNavigationTarget, handoffRefusedAt, isHandoffSessionFresh, @@ -184,6 +185,26 @@ describe('isHandoffSessionFresh — the route said when it refused', () => { }); }); +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}`; 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 fa98093fd47..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,6 +91,12 @@ function createMockClient({ arrival = null, config = {}, organizations }: Client return client as typeof client & AuthClient; } +/** 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}`); @@ -98,6 +104,7 @@ function renderPage(page: 'login' | 'register', client: AuthClient, redirect?: s {page === 'login' ? : } + , ); @@ -268,6 +275,100 @@ describe('LoginPage — rule 5: the route said when it refused', () => { }); }); +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 c5b1b3bb2bb..e9857ebf679 100644 --- a/ui/apps/console/src/pages/auth/handoffRedirect.ts +++ b/ui/apps/console/src/pages/auth/handoffRedirect.ts @@ -34,7 +34,9 @@ * 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}). + * ({@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'; @@ -118,13 +120,29 @@ export function handoffRefusedAt(search: string): number | null { 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` 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. With a `createdAt` alone, its age decides. Without one, the + * 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