diff --git a/client/public/native-handoff.js b/client/public/native-handoff.js new file mode 100644 index 000000000..61d8fb827 --- /dev/null +++ b/client/public/native-handoff.js @@ -0,0 +1,43 @@ +// The page /api/auth/native/start answers with runs this (see NativeAuthController.start). +// A browser that ran an older TREK keeps that version's app in its service worker, and +// the worker would answer the handoff page with an app that has no such page. So the +// worker goes first, and the handoff page loads from the server. +(function () { + var script = document.currentScript; + var target = script && script.getAttribute('data-target'); + if (!target || target.indexOf('/native-handoff?') !== 0) return; + var gone = false; + var go = function () { + if (gone) return; + gone = true; + location.replace(target); + }; + // Until the sign-in is handed back to the app, no worker is registered and no + // new version reloads the page (src/native/browserSignInFlag.ts): a tab still + // open on the older TREK keeps its worker alive, and either would bring it back. + try { + localStorage.setItem('trek_native_signin', String(Date.now())); + } catch (e) { + /* site data blocked: the worker is still taken off below */ + } + if (!('serviceWorker' in navigator) || !navigator.serviceWorker.getRegistrations) { + go(); + return; + } + // Never stuck here: whatever the browser does with the workers, the page moves on. + var timer = setTimeout(go, 3000); + navigator.serviceWorker + .getRegistrations() + .then(function (registrations) { + return Promise.all( + registrations.map(function (registration) { + return registration.unregister(); + }) + ); + }) + .catch(function () {}) + .then(function () { + clearTimeout(timer); + go(); + }); +})(); diff --git a/client/public/registerSW.js b/client/public/registerSW.js new file mode 100644 index 000000000..f33c88e72 --- /dev/null +++ b/client/public/registerSW.js @@ -0,0 +1,18 @@ +// Registers the service worker, as vite-plugin-pwa's own registerSW.js does; this +// file in public/ takes its place. Not while the app's sign-in runs in this browser +// (src/native/browserSignInFlag.ts): a tab still open on an older TREK keeps that +// version's worker alive, and registering now would bring it back to answer the +// sign-in's next page with an app that has no handoff page. +(function () { + if (!('serviceWorker' in navigator)) return; + try { + var started = Number(localStorage.getItem('trek_native_signin')); + var age = Date.now() - started; + if (started > 0 && age >= 0 && age < 15 * 60 * 1000) return; + } catch (e) { + /* site data blocked: register as usual */ + } + window.addEventListener('load', function () { + navigator.serviceWorker.register('/sw.js', { scope: '/' }); + }); +})(); diff --git a/client/src/native/browserSignIn.test.ts b/client/src/native/browserSignIn.test.ts index cc94936ad..817809a66 100644 --- a/client/src/native/browserSignIn.test.ts +++ b/client/src/native/browserSignIn.test.ts @@ -44,10 +44,12 @@ describe('requestHandoffCode', () => { }); describe('handoffUrl', () => { - it('points at the hand-off page with the challenge and an optional start', () => { - expect(handoffUrl('https://trek.example.com', 'c')).toBe('https://trek.example.com/native-handoff?challenge=c'); + it('starts at the server page in front of the hand-off page, with the challenge and an optional start', () => { + // Under /api, which every TREK service worker passes to the server: an older + // version's worker must not answer the hand-off page from its cache. + expect(handoffUrl('https://trek.example.com', 'c')).toBe('https://trek.example.com/api/auth/native/start?challenge=c'); expect(handoffUrl('https://trek.example.com', 'c', 'oidc')).toBe( - 'https://trek.example.com/native-handoff?challenge=c&start=oidc' + 'https://trek.example.com/api/auth/native/start?challenge=c&start=oidc' ); }); }); @@ -59,7 +61,7 @@ describe('signInThroughBrowser', () => { await signInThroughBrowser('oidc'); const opened = new URL(vi.mocked(TrekShell.authenticate).mock.calls[0][0].url); - expect(opened.pathname).toBe('/native-handoff'); + expect(opened.pathname).toBe('/api/auth/native/start'); expect(opened.searchParams.get('start')).toBe('oidc'); expect(vi.mocked(TrekShell.authenticate).mock.calls[0][0].callbackScheme).toBe('com.liketrek.trek'); const { code, verifier } = vi.mocked(nativeAuthApi.exchange).mock.calls[0][0]; diff --git a/client/src/native/browserSignIn.ts b/client/src/native/browserSignIn.ts index 64ed79e27..85fda28cc 100644 --- a/client/src/native/browserSignIn.ts +++ b/client/src/native/browserSignIn.ts @@ -21,6 +21,13 @@ import { APP_URL_SCHEME, TrekShell } from './trekShell'; export const HANDOFF_PATH = '/native-handoff'; +/** + * Where the browser is sent first: a page of the server's that takes an older + * TREK's service worker off the browser and then opens HANDOFF_PATH with the + * same query. Under /api, which every TREK service worker passes to the server. + */ +export const HANDOFF_START_PATH = '/api/auth/native/start'; + /** Where the browser starts: straight into SSO, or straight at the passkey prompt. */ export type BrowserSignInStart = 'oidc' | 'passkey'; @@ -65,7 +72,7 @@ export function createHandoffSecret(): { verifier: string; challenge: string } { } export function handoffUrl(origin: string, challenge: string, start?: BrowserSignInStart, invite?: string): string { - const url = new URL(HANDOFF_PATH, origin); + const url = new URL(HANDOFF_START_PATH, origin); url.searchParams.set('challenge', challenge); if (start) url.searchParams.set('start', start); if (invite) url.searchParams.set('invite', invite); diff --git a/client/src/native/browserSignInFlag.test.ts b/client/src/native/browserSignInFlag.test.ts new file mode 100644 index 000000000..6cae3d74c --- /dev/null +++ b/client/src/native/browserSignInFlag.test.ts @@ -0,0 +1,29 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { BROWSER_SIGN_IN_KEY, BROWSER_SIGN_IN_TTL_MS, browserSignInRunning, endBrowserSignIn } from './browserSignInFlag'; + +afterEach(() => localStorage.clear()); + +describe('browserSignInRunning', () => { + it('FE-NATIVE-SIGNINFLAG-001: counts a mark from public/native-handoff.js for as long as a sign-in may take', () => { + const now = 1_000_000_000; + expect(browserSignInRunning(now)).toBe(false); + + localStorage.setItem(BROWSER_SIGN_IN_KEY, String(now - 1000)); + expect(browserSignInRunning(now)).toBe(true); + + localStorage.setItem(BROWSER_SIGN_IN_KEY, String(now - BROWSER_SIGN_IN_TTL_MS)); + expect(browserSignInRunning(now)).toBe(false); + // A clock set back, or anything that is no time at all. + localStorage.setItem(BROWSER_SIGN_IN_KEY, String(now + 60_000)); + expect(browserSignInRunning(now)).toBe(false); + localStorage.setItem(BROWSER_SIGN_IN_KEY, 'soon'); + expect(browserSignInRunning(now)).toBe(false); + }); + + it('FE-NATIVE-SIGNINFLAG-002: ends with the hand-back to the app', () => { + localStorage.setItem(BROWSER_SIGN_IN_KEY, String(Date.now())); + endBrowserSignIn(); + expect(localStorage.getItem(BROWSER_SIGN_IN_KEY)).toBeNull(); + expect(browserSignInRunning()).toBe(false); + }); +}); diff --git a/client/src/native/browserSignInFlag.ts b/client/src/native/browserSignInFlag.ts new file mode 100644 index 000000000..a68be4c04 --- /dev/null +++ b/client/src/native/browserSignInFlag.ts @@ -0,0 +1,31 @@ +/** + * Marks the app's sign-in in the system browser while it runs. The page in front + * of the handoff page (public/native-handoff.js) sets it, so that until the user + * is handed back to the app no service worker is registered (public/registerSW.js) + * and no new version reloads the page (utils/versionHandover.ts). A browser tab + * still open on an older TREK keeps that version's worker alive, and a worker + * registered or a reload in the middle of the sign-in would bring it back to + * answer the next page with an app that has no handoff page. + */ +export const BROWSER_SIGN_IN_KEY = 'trek_native_signin'; + +/** As long as a sign-in in the browser may take; the mark counts for no longer. */ +export const BROWSER_SIGN_IN_TTL_MS = 15 * 60 * 1000; + +export function browserSignInRunning(now = Date.now()): boolean { + try { + const started = Number(localStorage.getItem(BROWSER_SIGN_IN_KEY)); + return Number.isFinite(started) && started > 0 && now - started >= 0 && now - started < BROWSER_SIGN_IN_TTL_MS; + } catch { + return false; + } +} + +/** The sign-in is handed back to the app: the browser goes back to normal. */ +export function endBrowserSignIn(): void { + try { + localStorage.removeItem(BROWSER_SIGN_IN_KEY); + } catch { + /* site data blocked: the mark runs out by itself */ + } +} diff --git a/client/src/pages/nativeHandoff/useNativeHandoff.test.tsx b/client/src/pages/nativeHandoff/useNativeHandoff.test.tsx index 8414dc9fd..7e9e0f4f1 100644 --- a/client/src/pages/nativeHandoff/useNativeHandoff.test.tsx +++ b/client/src/pages/nativeHandoff/useNativeHandoff.test.tsx @@ -102,6 +102,7 @@ describe('useNativeHandoff', () => { it('FE-NATIVE-HANDOFF-006: only mints the code on the tap, then hands it to the app scheme', async () => { vi.mocked(requestHandoffCode).mockResolvedValue('c/1'); + localStorage.setItem('trek_native_signin', String(Date.now())); const { result } = renderHandoff(); expect(requestHandoffCode).not.toHaveBeenCalled(); @@ -111,6 +112,8 @@ describe('useNativeHandoff', () => { expect(requestHandoffCode).toHaveBeenCalledWith(challenge); expect(window.location.href).toBe('com.liketrek.trek://auth?code=c%2F1'); + // Handed back: the browser registers its service worker again from here. + expect(localStorage.getItem('trek_native_signin')).toBeNull(); }); it('FE-NATIVE-HANDOFF-007: reports a failed hand-off', async () => { diff --git a/client/src/pages/nativeHandoff/useNativeHandoff.ts b/client/src/pages/nativeHandoff/useNativeHandoff.ts index 18d6e37bd..7300bbcfe 100644 --- a/client/src/pages/nativeHandoff/useNativeHandoff.ts +++ b/client/src/pages/nativeHandoff/useNativeHandoff.ts @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import { useNavigate } from 'react-router'; import { useShallow } from 'zustand/react/shallow'; import { HANDOFF_PATH, rememberHandoffReturn, requestHandoffCode, signInWithPasskey } from '../../native/browserSignIn'; +import { endBrowserSignIn } from '../../native/browserSignInFlag'; import { APP_URL_SCHEME } from '../../native/trekShell'; import { useAuthStore } from '../../store/authStore'; @@ -50,6 +51,7 @@ export function useNativeHandoff() { setStatus('sending'); try { const code = await requestHandoffCode(challenge); + endBrowserSignIn(); window.location.href = `${APP_URL_SCHEME}://auth?code=${encodeURIComponent(code)}`; } catch { setStatus('failed'); diff --git a/client/src/utils/versionHandover.test.ts b/client/src/utils/versionHandover.test.ts index 61e2c8bf6..fd4f37a7c 100644 --- a/client/src/utils/versionHandover.test.ts +++ b/client/src/utils/versionHandover.test.ts @@ -447,6 +447,30 @@ describe('reconcileAppVersion: a page loaded past an older worker', () => { }) }) +describe('reconcileAppVersion: during the app's sign-in in the browser', () => { + it('FE-UTIL-VERSION-058: leaves version and page alone while the sign-in runs, and acts again after it', async () => { + const { container } = installWorker(null) + container.getRegistration.mockResolvedValue(undefined) + const { reconcileAppVersion } = await loadModule() + localStorage.setItem('trek_app_version', '4.3.0') + // public/native-handoff.js marks the sign-in; a reload now could land on an + // older worker that a tab still open on the old version keeps alive. + localStorage.setItem('trek_native_signin', String(Date.now())) + + await reconcileAppVersion('4.3.1') + + expect(marker()).toBe('4.3.0') + expect(reload).not.toHaveBeenCalled() + expect(container.getRegistration).not.toHaveBeenCalled() + + // A mark older than any sign-in takes counts for nothing. + localStorage.setItem('trek_native_signin', String(Date.now() - 16 * 60 * 1000)) + await reconcileAppVersion('4.3.1') + expect(marker()).toBe('4.3.1') + expect(reload).toHaveBeenCalledTimes(1) + }) +}) + describe('reconcileAppVersion: the reload guard', () => { it('FE-UTIL-VERSION-030: a marker that cannot be written does not turn into a reload loop', async () => { const { reconcileAppVersion } = await loadModule() diff --git a/client/src/utils/versionHandover.ts b/client/src/utils/versionHandover.ts index 22e70eae0..96b709e2f 100644 --- a/client/src/utils/versionHandover.ts +++ b/client/src/utils/versionHandover.ts @@ -17,6 +17,7 @@ * version and session. */ +import { browserSignInRunning } from '../native/browserSignInFlag' import { reloadFresh } from './chunkReload' import { currentController, servingWorker } from './serviceWorkerShell' @@ -198,6 +199,9 @@ function waitForTakeover(reg: ServiceWorkerRegistration, version: string): Promi export async function reconcileAppVersion(reported: unknown): Promise { const version = asReleaseTag(reported) if (!version) return + // The app's sign-in runs in this browser: a reload now could land on an older + // version's worker that a tab still open keeps alive (native/browserSignInFlag). + if (browserSignInRunning()) return let storedVersion: string | null try { diff --git a/server/src/nest/common/validate-route-guards.ts b/server/src/nest/common/validate-route-guards.ts index 5106a88d2..c5370c933 100644 --- a/server/src/nest/common/validate-route-guards.ts +++ b/server/src/nest/common/validate-route-guards.ts @@ -145,6 +145,9 @@ export const PUBLIC_ROUTE_ALLOW_LIST: string[] = [ // The native app redeems a one-time code from the system browser here. The // code is only good together with the verifier the app kept to itself. 'NativeAuthController.exchange', + // Where the app's sign-in in the system browser starts: a page that forwards + // to the handoff page and carries no credential. + 'NativeAuthController.start', 'OauthPublicController.revoke', 'OauthPublicController.token', 'OauthPublicController.userinfo', diff --git a/server/src/nest/native-auth/native-auth.controller.ts b/server/src/nest/native-auth/native-auth.controller.ts index 256876def..c23f6e527 100644 --- a/server/src/nest/native-auth/native-auth.controller.ts +++ b/server/src/nest/native-auth/native-auth.controller.ts @@ -10,7 +10,7 @@ import { ResponseContract } from '../common/response-contract'; import { sessionClientFrom } from '../sessions/sessions.service'; import { NativeExchangeDto, NativeHandoffDto } from './native-auth.dto'; import { NativeAuthService } from './native-auth.service'; -import { Body, Controller, HttpCode, HttpException, Post, Req, Res, UseGuards } from '@nestjs/common'; +import { Body, Controller, Get, HttpCode, HttpException, Post, Query, Req, Res, UseGuards } from '@nestjs/common'; import { nativeHandoffResponseSchema, successResponseSchema, @@ -23,6 +23,36 @@ import type { Request, Response } from 'express'; const EXCHANGE_WINDOW_MS = 15 * 60 * 1000; const EXCHANGE_MAX = 20; +const CHALLENGE = /^[0-9a-f]{64}$/; +const START = new Set(['oidc', 'passkey']); +const INVITE = /^[A-Za-z0-9_-]{1,200}$/; + +/** `&`, `<`, `>` and `"` as entities, for a value inside a double-quoted attribute. */ +function attr(value: string): string { + return value.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +/** + * The page /api/auth/native/start answers with: it takes a service worker of an + * older TREK off the browser, then opens the handoff page (public/native-handoff.js). + * A browser that ran TREK before 5.1.1 keeps that version's app in its service + * worker, and the worker answers /native-handoff from its cache with an app that + * has no such page: the sign-in ended on the dashboard in the browser instead of + * in the app. Every TREK service worker lets /api through to the server, so this + * page always comes from the running version. + */ +export function nativeStartPage(target: string): string { + const href = attr(target); + return [ + '', + '', + 'TREK', + ``, + ``, + '', + ].join(''); +} + /** * /api/auth/native: the sign-in hand-off between the system browser and the * native app. See the contract in @trek/shared for the whole round trip. @@ -47,6 +77,34 @@ export class NativeAuthController { return { code: await this.nativeAuth.issueCode(user.id, body.challenge) }; } + /** + * Where the app's sign-in in the system browser starts. Answers a small page + * instead of the handoff page itself (see nativeStartPage); the query is the + * one the handoff page takes, checked here so nothing else rides along. + */ + // response-contract-exempt: an HTML page that forwards the browser, no body to check + @Get('start') + @Public('opens the handoff page in the browser; it carries no credential and shows nothing') + start( + @Query('challenge') challenge: unknown, + @Query('start') start: unknown, + @Query('invite') invite: unknown, + @Res() res: Response, + ): void { + if (typeof challenge !== 'string' || !CHALLENGE.test(challenge)) { + res.status(400).type('text/plain').send('Invalid sign-in link.'); + return; + } + const params = new URLSearchParams({ challenge }); + if (typeof start === 'string' && START.has(start)) params.set('start', start); + if (typeof invite === 'string' && INVITE.test(invite)) params.set('invite', invite); + res + .status(200) + .set('Cache-Control', 'no-store') + .type('text/html') + .send(nativeStartPage(`/native-handoff?${params.toString()}`)); + } + @Post('exchange') @Public('the one-time code together with its verifier is the credential') @HttpCode(200) diff --git a/server/tests/e2e/native-auth.e2e.test.ts b/server/tests/e2e/native-auth.e2e.test.ts index 81037cf3f..0924b2b7f 100644 --- a/server/tests/e2e/native-auth.e2e.test.ts +++ b/server/tests/e2e/native-auth.e2e.test.ts @@ -132,4 +132,14 @@ describe('Native sign-in hand-off e2e', () => { const blocked = await exchange({ code: 'nope', verifier }); expect(blocked.status).toBe(429); }); + + it('NATIVE-E2E-006: the browser starts at a page under /api that forwards to the handoff page', async () => { + const res = await request(server).get(`/api/auth/native/start?challenge=${challenge}&start=oidc`); + expect(res.status).toBe(200); + expect(res.headers['content-type']).toMatch(/text\/html/); + expect(res.headers['cache-control']).toBe('no-store'); + expect(res.text).toContain(`data-target="/native-handoff?challenge=${challenge}&start=oidc"`); + + expect((await request(server).get('/api/auth/native/start?challenge=nope')).status).toBe(400); + }); }); diff --git a/server/tests/unit/nest/native-auth.controller.test.ts b/server/tests/unit/nest/native-auth.controller.test.ts index 0075f0061..2060ef015 100644 --- a/server/tests/unit/nest/native-auth.controller.test.ts +++ b/server/tests/unit/nest/native-auth.controller.test.ts @@ -8,7 +8,7 @@ import type { AuditService } from '../../../src/nest/audit/audit.service'; import { DomainError } from '../../../src/nest/common/domain-error'; import type { RateLimitService } from '../../../src/nest/common/rate-limit.service'; import { RESPONSE_CONTRACT_KEY } from '../../../src/nest/common/response-contract'; -import { NativeAuthController } from '../../../src/nest/native-auth/native-auth.controller'; +import { NativeAuthController, nativeStartPage } from '../../../src/nest/native-auth/native-auth.controller'; import type { NativeAuthService } from '../../../src/nest/native-auth/native-auth.service'; import type { User } from '../../../src/types'; import { nativeHandoffResponseSchema, successResponseSchema } from '@trek/shared'; @@ -100,3 +100,48 @@ describe('NativeAuthController.exchange', () => { expect(Reflect.getMetadata(RESPONSE_CONTRACT_KEY, proto.exchange)).toBe(successResponseSchema); }); }); + +describe('NativeAuthController.start', () => { + function htmlRes() { + const res = { + status: vi.fn(), + set: vi.fn(), + type: vi.fn(), + send: vi.fn(), + }; + for (const fn of [res.status, res.set, res.type]) fn.mockReturnValue(res); + return res; + } + + it('NATIVE-CTL-007: answers the page that opens the handoff page with the same challenge, start and invite', () => { + const { controller } = build(); + const res = htmlRes(); + + controller.start(challenge, 'oidc', 'inv_A-1', res as unknown as Response); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.set).toHaveBeenCalledWith('Cache-Control', 'no-store'); + expect(res.type).toHaveBeenCalledWith('text/html'); + expect(res.send).toHaveBeenCalledWith( + nativeStartPage(`/native-handoff?challenge=${challenge}&start=oidc&invite=inv_A-1`), + ); + }); + + it('NATIVE-CTL-008: refuses a malformed challenge and drops a start or invite it does not know', () => { + const { controller } = build(); + const bad = htmlRes(); + controller.start('not-a-hash', 'oidc', undefined, bad as unknown as Response); + expect(bad.status).toHaveBeenCalledWith(400); + + const res = htmlRes(); + controller.start(challenge, 'evil', '">'); + expect(page).toContain(''); + expect(nativeStartPage('/native-handoff?x="<>')).not.toMatch(/"<>/); + }); +});