mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 15:51:00 +00:00
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:
@@ -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();
|
||||
});
|
||||
})();
|
||||
@@ -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: '/' });
|
||||
});
|
||||
})();
|
||||
@@ -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];
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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, '&').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 [
|
||||
'<!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)
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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&start=oidc"></script>');
|
||||
expect(page).toContain('<meta http-equiv="refresh" content="0;url=/native-handoff?challenge=a&start=oidc">');
|
||||
expect(nativeStartPage('/native-handoff?x="<>')).not.toMatch(/"<>/);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user