fix(native): the app's browser sign-in no longer lands on the dashboard when the browser still holds an older TREK

A browser that ran TREK before 5.1.1 keeps that version's app in its service
worker. When the app opened /native-handoff, the worker answered it from its
cache with an app that has no such page, which sent the user to /, and the
sign-in ended on the dashboard in the browser instead of in the app.

The app now opens /api/auth/native/start, which every TREK service worker
passes to the server. That page takes the workers off the browser and
forwards to the handoff page. Until the sign-in is handed back, the web app
registers no service worker and does not reload for a new version: a tab
still open on the older TREK keeps its worker alive, and a registration or a
reload in the middle of the sign-in brought it back to answer the next page.
This commit is contained in:
Maurice
2026-11-09 22:21:09 +01:00
parent 5420077b98
commit 034cfb539a
14 changed files with 286 additions and 7 deletions
+43
View File
@@ -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();
});
})();
+18
View File
@@ -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: '/' });
});
})();
+6 -4
View File
@@ -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];
+8 -1
View File
@@ -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);
@@ -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);
});
});
+31
View File
@@ -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 */
}
}
@@ -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 () => {
@@ -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');
+24
View File
@@ -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()
+4
View File
@@ -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<void> {
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 {
@@ -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',
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
/**
* 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 [
'<!doctype html>',
'<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">',
'<meta name="robots" content="noindex"><title>TREK</title>',
`<noscript><meta http-equiv="refresh" content="0;url=${href}"></noscript>`,
`<script src="/native-handoff.js" data-target="${href}"></script>`,
'</head><body></body></html>',
].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)
+10
View File
@@ -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}&amp;start=oidc"`);
expect((await request(server).get('/api/auth/native/start?challenge=nope')).status).toBe(400);
});
});
@@ -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', '"><script>', res as unknown as Response);
expect(res.send).toHaveBeenCalledWith(nativeStartPage(`/native-handoff?challenge=${challenge}`));
});
it('NATIVE-CTL-009: the page loads the forwarding script from the server and escapes the target', () => {
const page = nativeStartPage('/native-handoff?challenge=a&start=oidc');
expect(page).toContain('<script src="/native-handoff.js" data-target="/native-handoff?challenge=a&amp;start=oidc"></script>');
expect(page).toContain('<meta http-equiv="refresh" content="0;url=/native-handoff?challenge=a&amp;start=oidc">');
expect(nativeStartPage('/native-handoff?x="<>')).not.toMatch(/"<>/);
});
});