diff --git a/infrastructure/eid-wallet/docs/architecture/deepLink.md b/infrastructure/eid-wallet/docs/architecture/deepLink.md new file mode 100644 index 000000000..c1c1c45a8 --- /dev/null +++ b/infrastructure/eid-wallet/docs/architecture/deepLink.md @@ -0,0 +1,191 @@ +# Deep-link login + +How a `w3ds://` login request from a third-party site reaches the +Approve/Decline consent screen. + +## The problem + +A platform (Pictique, Blabsy, ...) shows a login QR. The user scans it with the +system camera or taps it, and Android hands the wallet a URL: + +``` +w3ds://auth?session=&platform=&redirect= +``` + +Showing the consent screen for that URL requires two independent things to +finish, in an order nobody controls: + +1. **The URL arriving.** The root layout imports the deep-link plugin + asynchronously, then asks it for the launch URL. +2. **The user authenticating.** On a cold start the splash prompts for + biometrics, which can succeed in ~200ms or take several seconds. + +Neither reliably happens first. On a cold start with a fingerprint already on +the sensor, authentication wins. On a slower unlock, the URL wins. + +## The design: whoever finishes last routes + +Both sides check the same two explicitly-recorded facts, so neither can act on +a half-finished picture: + +- **The URL arrives while unauthenticated** — store it, route nothing. The + screen that completes authentication picks it up. +- **The URL arrives while already authenticated** — route to the consent screen + immediately. +- **Authentication completes** — if a payload is stored, go to `/scan-qr`; + otherwise `/main`. + +```mermaid +flowchart TD + A["Android intent w3ds://"] --> B["root +layout
onOpenUrl / getCurrent"] + B --> C["parse payload"] + C --> D["storeDeepLink(payload)"] + D --> E{"globalState.sessionController.isAuthenticated?"} + E -- "no" --> F["route nothing:
the auth path will collect it"] + E -- "yes" --> G["goto /scan-qr"] + + S["splash +page"] --> S1["intro, poll globalState"] + S1 --> S2["authenticate() biometric"] + S2 -- "ok" --> P["continueAfterSuccessfulAuth"] + S2 -- "cancel / unavailable" --> L["/login PIN pad"] + L -- "pin ok" --> P + + P --> P1["sessionController.markAuthenticated()
BEFORE any await"] + P1 --> P2{"hasDeepLink()?"} + P2 -- "yes" --> G + P2 -- "no" --> M["goto /main"] + + G --> R["scanLogic: peekDeepLink()
open consent drawer"] + R --> R1["Approve -> POST + openUrl"] + R --> R2["Decline -> /main"] +``` + +## One payload slot + +Storing a deep link never implies permission to act on it. That is +`globalState.sessionController.isAuthenticated`, which every consumer checks anyway. + +An earlier version had two slots, `pendingDeepLink` and `deepLinkData`, and +"promoted" between them once the user authenticated. The copy carried no +information: the payload was byte-identical on both sides, and the only +difference was a label meaning "actionable now". The duplication leaked +outward — `/scan-qr` read one key and fell back to the other, then had to clear +both, and any reader forgetting a key was a silent bug. + +## Why authentication is recorded explicitly + +The original implementation asked +`isAuthenticatedRoute(window.location.pathname)` at the instant of delivery, as +a proxy for "has the user authenticated?". + +That is unsound on a cold start. The path is `/` (the splash) regardless of how +the race went, so a user who had **already** authenticated was still classified +as logged out. The payload was stored for a screen that had finished running, +nothing collected it, and the user landed on `/main` with the consent screen +never shown. That was the bug this design replaces. + +Authentication state is therefore written by the code that performs the +authentication, and never derived from the URL or the route. + +`sessionController.markAuthenticated()` must be called **before any await** that precedes the +caller's navigation. A deep link delivered while post-login chores are in +flight has to see the user as authenticated, or it will store a payload nobody +is left to collect. + +## Storage choice + +`sessionStorage`, deliberately — not a Svelte store, not `localStorage`, and +not an in-memory field on the controller. + +- **A Svelte store is in-memory.** This state has to survive the full-page + navigations the wallet performs between the splash, `/login` and `/scan-qr`. + An in-memory store is empty on the other side. +- **`localStorage` would survive the app being killed**, which is exactly wrong + for the authenticated flag. A deep link arriving after a cold start must + trigger a real authentication rather than inheriting one from a previous run. + Being forgotten on relaunch is the property that makes it safe. +- **An in-memory field would not survive the WEBVIEW being rebuilt**, which is + a different event from the app being killed. Android may reload the webview + while the app is backgrounded by `openUrl`, and the approve path does a + document navigation to the platform's redirect. Neither is a new run of the + app, and the flow has no way to re-prompt mid-handoff, so the user would be + stranded. `SessionController` therefore takes no store and reads + sessionStorage directly. + +Every accessor degrades to "nothing stored" when storage is unavailable +(private mode, storage disabled) rather than throwing, because these run inside +deep-link callbacks where a throw is invisible to the user and strands the flow. + +## Logout + +`GlobalState.reset()` clears the sign-in flag, via `SessionController.clear()`; +`performLogout()` clears the pending payload alongside it. This is required, not +defensive: logout does `goto("/")`, an SPA navigation that leaves +`sessionStorage` intact. Without it the session would keep claiming the user is +authenticated, and the next deep link would route straight to the consent screen +on the strength of a login that had already ended. + +## The splash is the only biometric prompt + +`/login` is the PIN fallback. The splash prompts for biometrics and routes +onward itself; it does not divert a deep-link launch to `/login`, because doing +so would downgrade a returning user to the PIN pad for the flow most likely to +be used in a hurry. + +`/login` contains no call to `authenticate()` at all, and the splash is the only +file in the app that imports it from `@tauri-apps/plugin-biometric`. Everything +else imports `checkStatus` to read availability. The guarantee is therefore +structural rather than coordinated: there is no flag to get wrong. + +An earlier design had both screens prompt, suppressed by a +`biometricAttemptedOnSplash` handshake. It was racy — the splash wrote the flag +only after two awaits resolved `biometricAvailable`, while `/login` read it +behind a globalState poll of up to five seconds, so anything routing to `/login` +inside that window got a second prompt. The flag is gone. + +One consequence worth knowing: the splash's async `onMount` carries liveness +checks (`destroyed`). Unmounting a Svelte component does not cancel a +continuation parked on an `await`, so it could otherwise wake after the consent +drawer opened and navigate away from it. + +The trade-off is deliberate: cancelling the biometric prompt leaves the user on +the PIN pad with no way to retry biometrics without relaunching. `/login` is +defined as the fallback, so that is the intended behaviour. + +## What authentication actually means here + +Worth stating plainly, because the names are misleading. + +| Fact | Question it answers | Lifetime | +|---|---|---| +| `vaultController.vault` | Is an identity **enrolled** on this device? | Disk, survives reboot | +| `securityController.pinHash` | Is a PIN **configured**? | Disk | +| `walletAuthenticated` | Has the user authenticated **this session**? | sessionStorage | + +`walletAuthenticated` is owned by `GlobalState.sessionController`, alongside the +other controllers. It is the session's authentication state, not a deep-link +concept; the deep-link flow is simply its only reader today. `lib/stores/deepLink.ts` +owns only the pending payload. + +The `(app)` route guard checks the **vault**, i.e. enrolment, not +authentication. It stops a never-onboarded or logged-out user; it does not stop +an unauthenticated one, since a cold-start user has a vault sitting on disk. + +What actually forces authentication on a cold start is that the splash owns the +only normal path into `(app)`, plus the fact that `walletAuthenticated` dies +with the webview. A deep link is a **second door** into the app, which is why it +needs an explicit flag to consult rather than relying on that implicit +guarantee. + +## Known gaps + +- **Not covered by tests:** vitest here is node-only and mounts no Svelte + components, so the specs pin this module's semantics, not the call sites in + `.svelte` files. Device testing via `pnpm build:apk` is the only real proof. +- **PIN change and passphrase rotation** have not been traced. If either ends a + session without going through `globalState.reset()`, the authenticated flag + would survive when it should not. +- **The Ok confirmation card is not shown after a deep-link login.** Approving + runs `goto("/main")` before `openUrl`, which unmounts the page that owns the + drawer, and the subsequent Activity restart wipes `sessionStorage` anyway. + This is pre-existing behaviour, unrelated to the race, and still open. diff --git a/infrastructure/eid-wallet/package.json b/infrastructure/eid-wallet/package.json index 2c830ea8b..671d8b33f 100644 --- a/infrastructure/eid-wallet/package.json +++ b/infrastructure/eid-wallet/package.json @@ -1,6 +1,6 @@ { "name": "eid-wallet", - "version": "1.0.1", + "version": "1.1.1", "description": "", "type": "module", "scripts": { diff --git a/infrastructure/eid-wallet/src-tauri/gen/apple/eid-wallet.xcodeproj/project.pbxproj b/infrastructure/eid-wallet/src-tauri/gen/apple/eid-wallet.xcodeproj/project.pbxproj index 6f0906889..8aa1c5de3 100644 --- a/infrastructure/eid-wallet/src-tauri/gen/apple/eid-wallet.xcodeproj/project.pbxproj +++ b/infrastructure/eid-wallet/src-tauri/gen/apple/eid-wallet.xcodeproj/project.pbxproj @@ -245,7 +245,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/zsh; - shellScript = "[ -s \"$NVM_DIR/nvm.sh\" ] && \\. \"$NVM_DIR/nvm.sh\"\npnpm tauri ios xcode-script -v --platform ${PLATFORM_DISPLAY_NAME:?} --sdk-root ${SDKROOT:?} --framework-search-paths \"${FRAMEWORK_SEARCH_PATHS:?}\" --header-search-paths \"${HEADER_SEARCH_PATHS:?}\" --gcc-preprocessor-definitions \"${GCC_PREPROCESSOR_DEFINITIONS:-}\" --configuration ${CONFIGURATION:?} ${FORCE_COLOR} ${ARCHS:?}\n"; + shellScript = "[ -s \"$NVM_DIR/nvm.sh\" ] && \\. \"$NVM_DIR/nvm.sh\"\nexport PATH=\"$HOME/.local/share/mise/shims:$HOME/.volta/bin:$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH\"\npnpm tauri ios xcode-script -v --platform ${PLATFORM_DISPLAY_NAME:?} --sdk-root ${SDKROOT:?} --framework-search-paths \"${FRAMEWORK_SEARCH_PATHS:?}\" --header-search-paths \"${HEADER_SEARCH_PATHS:?}\" --gcc-preprocessor-definitions \"${GCC_PREPROCESSOR_DEFINITIONS:-}\" --configuration ${CONFIGURATION:?} ${FORCE_COLOR} ${ARCHS:?}\n"; }; /* End PBXShellScriptBuildPhase section */ @@ -388,7 +388,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = "eid-wallet_iOS/eid-wallet_iOS.entitlements"; CODE_SIGN_IDENTITY = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 1.0.1; + CURRENT_PROJECT_VERSION = 1.1.1.1; DEVELOPMENT_TEAM = M49C8XS835; ENABLE_BITCODE = NO; "EXCLUDED_ARCHS[sdk=iphoneos*]" = x86_64; @@ -415,8 +415,8 @@ "$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)", "$(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)", ); - MARKETING_VERSION = 1.0.1; - PRODUCT_BUNDLE_IDENTIFIER = foundation.metastate.eid-wallet; + MARKETING_VERSION = 1.1.1; + PRODUCT_BUNDLE_IDENTIFIER = "foundation.metastate.eid-wallet"; PRODUCT_NAME = "eID for W3DS"; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -436,7 +436,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = "eid-wallet_iOS/eid-wallet_iOS.entitlements"; CODE_SIGN_IDENTITY = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 1.0.1; + CURRENT_PROJECT_VERSION = 1.1.1.1; DEVELOPMENT_TEAM = M49C8XS835; ENABLE_BITCODE = NO; "EXCLUDED_ARCHS[sdk=iphoneos*]" = x86_64; @@ -463,8 +463,8 @@ "$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)", "$(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)", ); - MARKETING_VERSION = 1.0.1; - PRODUCT_BUNDLE_IDENTIFIER = foundation.metastate.eid-wallet; + MARKETING_VERSION = 1.1.1; + PRODUCT_BUNDLE_IDENTIFIER = "foundation.metastate.eid-wallet"; PRODUCT_NAME = "eID for W3DS"; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; diff --git a/infrastructure/eid-wallet/src-tauri/gen/apple/eid-wallet_iOS/Info.plist b/infrastructure/eid-wallet/src-tauri/gen/apple/eid-wallet_iOS/Info.plist index 546198af6..65eb46a89 100644 --- a/infrastructure/eid-wallet/src-tauri/gen/apple/eid-wallet_iOS/Info.plist +++ b/infrastructure/eid-wallet/src-tauri/gen/apple/eid-wallet_iOS/Info.plist @@ -15,7 +15,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.0.1 + 1.1.1 CFBundleURLTypes @@ -28,7 +28,7 @@ CFBundleVersion - 1.0.1 + 1.1.1.1 LSRequiresIPhoneOS NSAppTransportSecurity @@ -61,4 +61,4 @@ UIInterfaceOrientationLandscapeRight - \ No newline at end of file + diff --git a/infrastructure/eid-wallet/src-tauri/gen/apple/project.yml b/infrastructure/eid-wallet/src-tauri/gen/apple/project.yml index acf221017..08b82d5ff 100644 --- a/infrastructure/eid-wallet/src-tauri/gen/apple/project.yml +++ b/infrastructure/eid-wallet/src-tauri/gen/apple/project.yml @@ -80,7 +80,12 @@ targets: - sdk: UIKit.framework - sdk: WebKit.framework preBuildScripts: - - script: pnpm tauri ios xcode-script -v --platform ${PLATFORM_DISPLAY_NAME:?} --sdk-root ${SDKROOT:?} --framework-search-paths "${FRAMEWORK_SEARCH_PATHS:?}" --header-search-paths "${HEADER_SEARCH_PATHS:?}" --gcc-preprocessor-definitions "${GCC_PREPROCESSOR_DEFINITIONS:-}" --configuration ${CONFIGURATION:?} ${FORCE_COLOR} ${ARCHS:?} + # Xcode launched from the Dock gets a minimal PATH, so node/pnpm from a + # version manager are invisible. Cover the common install locations. + - script: | + [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" + export PATH="$HOME/.local/share/mise/shims:$HOME/.volta/bin:$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH" + pnpm tauri ios xcode-script -v --platform ${PLATFORM_DISPLAY_NAME:?} --sdk-root ${SDKROOT:?} --framework-search-paths "${FRAMEWORK_SEARCH_PATHS:?}" --header-search-paths "${HEADER_SEARCH_PATHS:?}" --gcc-preprocessor-definitions "${GCC_PREPROCESSOR_DEFINITIONS:-}" --configuration ${CONFIGURATION:?} ${FORCE_COLOR} ${ARCHS:?} name: Build Rust Code basedOnDependencyAnalysis: false outputFiles: diff --git a/infrastructure/eid-wallet/src-tauri/tauri.conf.json b/infrastructure/eid-wallet/src-tauri/tauri.conf.json index 31b0e52b0..197629bef 100644 --- a/infrastructure/eid-wallet/src-tauri/tauri.conf.json +++ b/infrastructure/eid-wallet/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "eID for W3DS", - "version": "1.0.1", + "version": "1.1.1", "identifier": "foundation.metastate.eid-wallet", "build": { "beforeDevCommand": "pnpm dev", @@ -29,7 +29,7 @@ "active": true, "targets": "all", "android": { - "versionCode": 28 + "versionCode": 31 }, "icon": [ "icons/32x32.png", diff --git a/infrastructure/eid-wallet/src/lib/global/controllers/session.ts b/infrastructure/eid-wallet/src/lib/global/controllers/session.ts new file mode 100644 index 000000000..19104c621 --- /dev/null +++ b/infrastructure/eid-wallet/src/lib/global/controllers/session.ts @@ -0,0 +1,72 @@ +/** + * Runtime state for the current run of the app, as opposed to the persisted + * configuration the other controllers own. + * + * Every other controller wraps the Tauri store, so everything they hold + * survives the app being killed: is a PIN set, is an identity enrolled, are + * biometrics enabled. Those are configuration questions. + * + * "Has the user authenticated?" is not one of them. It must be forgotten when + * the app is killed, or a deep link arriving on a cold start would inherit a + * login from a previous run and skip the prompt entirely. + * + * It must equally SURVIVE the webview being rebuilt, which is not the same + * event. Approving a deep-link login hands off to the browser via openUrl, and + * Android is free to reload the backgrounded webview; the approve path also + * does a document navigation to the platform's redirect. Neither is a new run + * of the app, and the current flow has no way to re-prompt in the middle of + * one, so an in-memory field would strand the user. + * + * sessionStorage is exactly that lifetime: dies with the tab/app, survives a + * reload. Hence a controller that, unlike its siblings, takes no Store and + * reads sessionStorage directly. + * + * See docs/architecture/deepLink.md. + */ +export class SessionController { + static readonly #AUTHENTICATED_KEY = "walletAuthenticated"; + + #storage(): Storage | null { + try { + return typeof sessionStorage === "undefined" + ? null + : sessionStorage; + } catch { + // Private mode / storage disabled. Degrade to "not authenticated" + // rather than throwing inside a deep-link callback, where a throw + // is invisible to the user and strands the flow. + return null; + } + } + + /** + * Record that the user is through the authentication gate. + * + * Callers must do this BEFORE any await that precedes their navigation, so + * a deep link delivered mid-flight sees the user as authenticated and + * routes itself rather than storing a payload nobody is left to collect. + */ + markAuthenticated(): void { + this.#storage()?.setItem(SessionController.#AUTHENTICATED_KEY, "true"); + } + + get isAuthenticated(): boolean { + return ( + this.#storage()?.getItem(SessionController.#AUTHENTICATED_KEY) === + "true" + ); + } + + /** + * Called by GlobalState.reset() on logout. + * + * Required, not defensive: logout does an SPA navigation to "/", which + * leaves sessionStorage intact. Without this the session would keep + * claiming the user is authenticated, and the next deep link would route + * itself straight to the consent screen on the strength of a login that + * has already ended. + */ + async clear(): Promise { + this.#storage()?.removeItem(SessionController.#AUTHENTICATED_KEY); + } +} diff --git a/infrastructure/eid-wallet/src/lib/global/state.ts b/infrastructure/eid-wallet/src/lib/global/state.ts index 477e3b4e3..27af96640 100644 --- a/infrastructure/eid-wallet/src/lib/global/state.ts +++ b/infrastructure/eid-wallet/src/lib/global/state.ts @@ -5,6 +5,7 @@ import { createKeyServiceCryptoAdapter } from "../wallet-sdk-adapter"; import { VaultController } from "./controllers/evault"; import { KeyService } from "./controllers/key"; import { SecurityController } from "./controllers/security"; +import { SessionController } from "./controllers/session"; import { UserController } from "./controllers/user"; /** * @author SoSweetHam @@ -28,6 +29,7 @@ export class GlobalState { #store: Store; #walletSdkAdapter: CryptoAdapter; securityController: SecurityController; + sessionController: SessionController; userController: UserController; vaultController: VaultController; notificationService: NotificationService; @@ -41,6 +43,7 @@ export class GlobalState { this.#store = store; this.#walletSdkAdapter = createKeyServiceCryptoAdapter(keyService); this.securityController = new SecurityController(store); + this.sessionController = new SessionController(); this.userController = new UserController(store); this.keyService = keyService; this.vaultController = new VaultController( @@ -158,6 +161,7 @@ export class GlobalState { async reset() { try { await this.securityController.clear(); + await this.sessionController.clear(); await this.userController.clear(); await this.vaultController.clear(); await this.keyService.clear(); diff --git a/infrastructure/eid-wallet/src/lib/stores/deepLink.spec.ts b/infrastructure/eid-wallet/src/lib/stores/deepLink.spec.ts new file mode 100644 index 000000000..b126ac884 --- /dev/null +++ b/infrastructure/eid-wallet/src/lib/stores/deepLink.spec.ts @@ -0,0 +1,362 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { goto } from "$app/navigation"; +import type { GlobalState } from "$lib/global"; +import { SessionController } from "$lib/global/controllers/session"; +import { + completeOnboarding, + continueAfterSuccessfulAuth, +} from "$lib/utils/postLogin"; +import { handleDeepLinkEvent, routeDeepLink } from "$lib/utils/routeDeepLink"; +import { clearDeepLink, hasDeepLink, peekDeepLink } from "./deepLink"; + +vi.mock("$app/navigation", () => ({ goto: vi.fn(async () => {}) })); + +/** Minimal sessionStorage stand-in; the module is deliberately storage-backed. */ +class MemoryStorage implements Storage { + private map = new Map(); + get length() { + return this.map.size; + } + clear() { + this.map.clear(); + } + getItem(key: string) { + return this.map.get(key) ?? null; + } + key(index: number) { + return Array.from(this.map.keys())[index] ?? null; + } + removeItem(key: string) { + this.map.delete(key); + } + setItem(key: string, value: string) { + this.map.set(key, String(value)); + } +} + +let session: SessionController; +let globalState: GlobalState; + +/** + * The slice of GlobalState the two functions under test actually touch: + * the session gate, plus the post-login chores, which are fire-and-forget and + * must not influence routing. The vault rejects to prove that. + */ +function makeGlobalState( + session: SessionController, + onVaultRead: () => Promise = async () => { + throw new Error("no vault in tests"); + }, +): GlobalState { + return { + isOnboardingComplete: false, + sessionController: session, + vaultController: { + get vault() { + return onVaultRead(); + }, + }, + } as unknown as GlobalState; +} + +/** Events routeDeepLink() broadcast for an already-mounted /scan-qr. */ +let dispatched: string[]; +/** Listeners registered on the stubbed window, as the real one would hold. */ +let listeners: ((event: Event) => void)[]; +/** Stands in for goto() in handleDeepLinkEvent. */ +let navigate: ReturnType; + +beforeEach(() => { + vi.stubGlobal("sessionStorage", new MemoryStorage()); + vi.mocked(goto).mockClear(); + dispatched = []; + listeners = []; + navigate = vi.fn(async () => {}); + // The node environment has no DOM; routeDeepLink() notifies a mounted + // /scan-qr through window and reads the current path off window.location. + vi.stubGlobal("window", { + // Record the event, then deliver it to every listener, so a handler + // that dispatches the event it listens for really does re-enter. + dispatchEvent: (event: Event) => { + dispatched.push(event.type); + for (const listener of listeners) listener(event); + }, + location: { pathname: "/" }, + }); + vi.stubGlobal( + "CustomEvent", + class { + type: string; + detail: unknown; + constructor(type: string, init?: { detail?: unknown }) { + this.type = type; + this.detail = init?.detail; + } + }, + ); + session = new SessionController(); + globalState = makeGlobalState(session); +}); + +const PAYLOAD = { + type: "auth", + session: "21fcc8a5", + platform: "pictique", + redirect: "https://pictique.example/api/auth", +}; + +/** + * The layout's half of the rendezvous, calling the shipped routeDeepLink(). + * Returns where the layout sent the user, or null when it parked the payload + * and routed nothing. + */ +function layoutRouteDeepLink(): string | null { + vi.mocked(goto).mockClear(); + routeDeepLink(globalState, PAYLOAD); + return destinationFromGoto(); +} + +/** + * The shipped continueAfterSuccessfulAuth(), which every authentication path + * (biometric on the splash, PIN on /login) funnels through. + */ +async function completeAuthentication(): Promise { + vi.mocked(goto).mockClear(); + await continueAfterSuccessfulAuth(globalState); + return destinationFromGoto(); +} + +/** Where the code under test navigated, if it navigated at all. */ +function destinationFromGoto(): string | null { + const calls = vi.mocked(goto).mock.calls; + return calls.length ? String(calls[calls.length - 1][0]) : null; +} + +/** What /scan-qr finds on mount: a payload to consent to, or nothing. */ +function scanQrSeesPayload(): boolean { + return peekDeepLink() !== null; +} + +describe("deep-link login rendezvous", () => { + /** + * THE ORIGINAL BUG. Biometrics succeed before the deep-link plugin has + * finished loading, so authentication completes first and the URL lands + * afterwards. The old code inferred auth from window.location.pathname, + * which is "/" on the splash either way, so this ordering was misread as + * "logged out": the payload was parked for a screen that had already + * finished and the user was dropped on /main. + */ + it("routes to consent when authentication WINS the race", async () => { + const authDestination = await completeAuthentication(); + expect(authDestination).toBe("/main"); + + const layoutDestination = layoutRouteDeepLink(); + + expect(layoutDestination).toBe("/scan-qr"); + expect(scanQrSeesPayload()).toBe(true); + }); + + /** + * The slow-authentication ordering: the URL arrives while the user is + * still on the sensor. The layout parks it and routes nothing, then the + * authentication path collects it. + */ + it("routes to consent when the deep link WINS the race", async () => { + const layoutDestination = layoutRouteDeepLink(); + expect(layoutDestination).toBeNull(); + + const authDestination = await completeAuthentication(); + + expect(authDestination).toBe("/scan-qr"); + expect(scanQrSeesPayload()).toBe(true); + }); + + it("sends a plain launch to /main, with no payload to consent to", async () => { + expect(await completeAuthentication()).toBe("/main"); + expect(scanQrSeesPayload()).toBe(false); + }); + + it("routes an already-authenticated user straight to consent", async () => { + await completeAuthentication(); + expect(layoutRouteDeepLink()).toBe("/scan-qr"); + expect(scanQrSeesPayload()).toBe(true); + }); + + it("keeps the payload readable until the consent screen clears it", async () => { + layoutRouteDeepLink(); + await completeAuthentication(); + expect(scanQrSeesPayload()).toBe(true); + + clearDeepLink(); + expect(scanQrSeesPayload()).toBe(false); + }); + + it("does not resurrect a payload the consent screen already consumed", async () => { + layoutRouteDeepLink(); + expect(await completeAuthentication()).toBe("/scan-qr"); + clearDeepLink(); + + expect(await completeAuthentication()).toBe("/main"); + }); + + /** + * Retrying a login the user declined must work. Platforms mint one + * `session` per offer, so the retry URL is byte-identical; nothing here + * may treat a repeat as permanently spent. + */ + it("lets the same URL be presented again after it was dismissed", async () => { + layoutRouteDeepLink(); + await completeAuthentication(); + clearDeepLink(); + + expect(layoutRouteDeepLink()).toBe("/scan-qr"); + expect(scanQrSeesPayload()).toBe(true); + }); + + /** + * Logout does an SPA navigation to "/", which leaves sessionStorage + * intact. Without SessionController.clear() the session would keep claiming + * the user is authenticated and the next deep link would skip the gate. + */ + it("forgets authentication on logout so the next link re-prompts", async () => { + await completeAuthentication(); + expect(session.isAuthenticated).toBe(true); + + // What GlobalState.reset() does on logout. + await session.clear(); + clearDeepLink(); + + expect(session.isAuthenticated).toBe(false); + expect(layoutRouteDeepLink()).toBeNull(); + }); + + /** + * The webview can be rebuilt without the app being killed: Android may + * reload it while the app is backgrounded by openUrl, and the approve path + * does a document navigation to the platform's redirect. The flow has no + * way to re-prompt mid-handoff, so authentication must survive that. + * + * A fresh SessionController reading the same sessionStorage is exactly + * what a rebuilt webview sees. An in-memory field would fail this. + */ + it("keeps the user authenticated across a webview rebuild", async () => { + await completeAuthentication(); + layoutRouteDeepLink(); + + const rebuilt = new SessionController(); + + expect(rebuilt.isAuthenticated).toBe(true); + expect(hasDeepLink()).toBe(true); + }); + + /** + * continueAfterSuccessfulAuth() awaits the vault before it routes. A deep + * link delivered inside that window must find the user already through the + * gate, which is why markAuthenticated() runs before the first await. + * Marking it afterwards puts the layout back to reading a stale "logged + * out" and parking the payload for a screen that has already finished. + */ + it("is authenticated for a link arriving mid-login, before routing", async () => { + let seenByLayout: string | null = "never ran"; + globalState = makeGlobalState(session, async () => { + // The deep link lands while the post-login chores are in flight. + seenByLayout = layoutRouteDeepLink(); + throw new Error("no vault in tests"); + }); + + const authDestination = await completeAuthentication(); + + expect(seenByLayout).toBe("/scan-qr"); + expect(authDestination).toBe("/scan-qr"); + }); + + /** + * The layout's deepLinkReceived handler is itself registered for + * deepLinkReceived. It must never dispatch that event: doing so re-enters + * the handler synchronously and recurses until the stack overflows. When + * /scan-qr is the current route it has already received the original event + * through its own listener, so there is nothing left to deliver. + */ + it("does not re-dispatch the event it is itself listening for", () => { + window.location.pathname = "/scan-qr"; + + // Wire the handler up exactly as the layout does. + const handler = (event: Event) => + handleDeepLinkEvent((event as CustomEvent).detail, true, navigate); + listeners.push(handler); + + expect(() => + window.dispatchEvent( + new CustomEvent("deepLinkReceived", { detail: PAYLOAD }), + ), + ).not.toThrow(); + + // One delivery, not thousands, and no navigation to a route we are on. + expect(dispatched).toEqual(["deepLinkReceived"]); + expect(navigate).not.toHaveBeenCalled(); + // The payload is still there for the mount that may not have listened. + expect(scanQrSeesPayload()).toBe(true); + }); + + it("stores and navigates when the consent screen is not open", () => { + window.location.pathname = "/main"; + + handleDeepLinkEvent(PAYLOAD, true, navigate); + + expect(navigate).toHaveBeenCalledWith("/scan-qr"); + expect(scanQrSeesPayload()).toBe(true); + }); + + it("parks the payload when the app has not finished starting", () => { + window.location.pathname = "/"; + + handleDeepLinkEvent(PAYLOAD, false, navigate); + + expect(navigate).not.toHaveBeenCalled(); + expect(scanQrSeesPayload()).toBe(true); + }); + + /** + * A user who has just onboarded or recovered has never passed through the + * splash or /login, so nothing else marks them signed in. Creating or + * restoring an identity IS proving it, and a deep link that arrived during + * onboarding has to be actionable the moment they land; otherwise the + * layout parks it and the consent screen ambushes them later, whenever + * something next mounts /scan-qr. + */ + it("treats finishing onboarding as being signed in", async () => { + layoutRouteDeepLink(); + expect(session.isAuthenticated).toBe(false); + + vi.mocked(goto).mockClear(); + await completeOnboarding(globalState); + + expect(session.isAuthenticated).toBe(true); + expect(destinationFromGoto()).toBe("/scan-qr"); + expect(scanQrSeesPayload()).toBe(true); + }); + + it("sends a plain onboarding finish to /main", async () => { + vi.mocked(goto).mockClear(); + await completeOnboarding(globalState); + + expect(destinationFromGoto()).toBe("/main"); + expect(globalState.isOnboardingComplete).toBe(true); + }); + + /** A link arriving after onboarding must route immediately, not be parked. */ + it("routes a link that arrives just after onboarding", async () => { + await completeOnboarding(globalState); + + expect(layoutRouteDeepLink()).toBe("/scan-qr"); + }); + + it("survives storage being unavailable without throwing", async () => { + vi.stubGlobal("sessionStorage", undefined); + + expect(() => layoutRouteDeepLink()).not.toThrow(); + await expect(completeAuthentication()).resolves.not.toThrow(); + expect(peekDeepLink()).toBeNull(); + }); +}); diff --git a/infrastructure/eid-wallet/src/lib/stores/deepLink.ts b/infrastructure/eid-wallet/src/lib/stores/deepLink.ts new file mode 100644 index 000000000..50cec063a --- /dev/null +++ b/infrastructure/eid-wallet/src/lib/stores/deepLink.ts @@ -0,0 +1,41 @@ +/** + * The pending deep-link payload. + * + * sessionStorage because the payload has to survive the full-page navigations + * the wallet performs between the splash, /login and /scan-qr, and the webview + * rebuild Android may perform while the app is backgrounded by openUrl. + * + * See docs/architecture/deepLink.md. + */ + +const PAYLOAD_KEY = "deepLinkData"; + +function store(): Storage | null { + try { + return typeof sessionStorage === "undefined" ? null : sessionStorage; + } catch { + // Private mode / storage disabled. Degrade to "no deep link" rather + // than throwing inside a deep-link callback. + return null; + } +} + +/** Store an incoming deep-link payload, whatever the authentication state. */ +export function storeDeepLink(data: unknown): void { + store()?.setItem(PAYLOAD_KEY, JSON.stringify(data)); +} + +/** The payload the consent screen should render, if any. */ +export function peekDeepLink(): string | null { + return store()?.getItem(PAYLOAD_KEY) ?? null; +} + +/** Is there a deep link waiting to be consented to? */ +export function hasDeepLink(): boolean { + return peekDeepLink() !== null; +} + +/** Clear the payload once the consent screen has shown it, or on logout. */ +export function clearDeepLink(): void { + store()?.removeItem(PAYLOAD_KEY); +} diff --git a/infrastructure/eid-wallet/src/lib/utils/postLogin.ts b/infrastructure/eid-wallet/src/lib/utils/postLogin.ts index d90085955..a8fe5e01a 100644 --- a/infrastructure/eid-wallet/src/lib/utils/postLogin.ts +++ b/infrastructure/eid-wallet/src/lib/utils/postLogin.ts @@ -1,5 +1,6 @@ import { goto } from "$app/navigation"; import type { GlobalState } from "$lib/global"; +import { hasDeepLink } from "$lib/stores/deepLink"; /** * Shared post-authentication routine: fires the background eVault chores @@ -7,13 +8,16 @@ import type { GlobalState } from "$lib/global"; * either to the deep-link target waiting in sessionStorage or to /main. * * Called from both the splash (when biometric auth succeeds over the - * splash screen) and from /login (after PIN or fallback biometric). - * Keeping the logic here means we don't have to flash the user through - * /login on biometric success. + * splash screen) and from /login (after PIN entry). Keeping the logic here + * means we don't have to flash the user through /login on biometric success. */ export async function continueAfterSuccessfulAuth( gs: GlobalState, ): Promise { + // Record the fact BEFORE any await: a deep link delivered while the chores + // below are in flight must see the user as already through the gate. + // See docs/architecture/deepLink.md. + gs.sessionController.markAuthenticated(); // Fire-and-forget post-login chores. They hit the network with no client // timeout, so awaiting them here can strand the user on a spinner — the // app pages will retry as needed. @@ -52,19 +56,36 @@ export async function continueAfterSuccessfulAuth( console.error("Error reading vault during login:", error); } - const pendingDeepLink = sessionStorage.getItem("pendingDeepLink"); - if (pendingDeepLink) { - try { - sessionStorage.setItem("deepLinkData", pendingDeepLink); - sessionStorage.removeItem("pendingDeepLink"); - await goto("/scan-qr"); - return; - } catch (error) { - console.error("Error processing pending deep link:", error); - sessionStorage.removeItem("pendingDeepLink"); - sessionStorage.removeItem("deepLinkData"); - } + // A deep link may have arrived before or during authentication; either way + // it is stored, and the user is now allowed to act on it. + if (hasDeepLink()) { + await goto("/scan-qr"); + return; } await goto("/main"); } + +/** + * Finish onboarding or recovery: the user has just proved their identity by + * creating or restoring it, so they are signed in for this session. + * + * Marking the session is what lets a deep link that arrived during onboarding + * be acted on straight away. Without it the layout's gate sees a user who has + * never been through the splash or /login, parks the payload, and the consent + * screen surfaces at some unrelated later moment instead. + * + * Mirrors continueAfterSuccessfulAuth()'s routing so both ways into the app + * honour a waiting deep link. + */ +export async function completeOnboarding(gs: GlobalState): Promise { + gs.isOnboardingComplete = true; + gs.sessionController.markAuthenticated(); + + if (hasDeepLink()) { + await goto("/scan-qr", { replaceState: true }); + return; + } + + await goto("/main", { replaceState: true }); +} diff --git a/infrastructure/eid-wallet/src/lib/utils/routeDeepLink.ts b/infrastructure/eid-wallet/src/lib/utils/routeDeepLink.ts new file mode 100644 index 000000000..2ce72d70d --- /dev/null +++ b/infrastructure/eid-wallet/src/lib/utils/routeDeepLink.ts @@ -0,0 +1,72 @@ +import { goto } from "$app/navigation"; +import type { GlobalState } from "$lib/global"; +import { storeDeepLink } from "$lib/stores/deepLink"; + +/** + * Route a parsed deep-link payload: the layout's half of the rendezvous. + * + * The payload is stored whatever the authentication state, so that whichever + * side of the race finishes second can pick it up. Only an authenticated user + * is routed onward; otherwise the payload is parked for + * continueAfterSuccessfulAuth() to collect. + * + * See docs/architecture/deepLink.md. + */ +export function routeDeepLink( + gs: GlobalState | undefined, + deepLinkData: Record, +): void { + // Store it either way: the payload is the same regardless of who + // ends up routing it. + storeDeepLink(deepLinkData); + + if (!gs?.sessionController.isAuthenticated) { + console.log("Deep link stored: user has not authenticated yet"); + return; + } + + console.log("Deep link routed: user is already authenticated"); + + // The event covers an already-mounted /scan-qr; the stored payload + // covers the mount that the goto() below triggers. + window.dispatchEvent( + new CustomEvent("deepLinkReceived", { detail: deepLinkData }), + ); + + if (window.location.pathname !== "/scan-qr") { + goto("/scan-qr").catch((error) => { + console.error("Error navigating to scan-qr:", error); + }); + } +} + +/** + * Handle a `deepLinkReceived` event in the root layout: the payload has + * already been parsed, and may have arrived while the app was still starting. + * + * `/scan-qr` registers its own `deepLinkReceived` listener, so when it is the + * current route it has already received this same event directly. This + * function must therefore never re-dispatch `deepLinkReceived`: the layout + * listens for that event itself, so a re-dispatch re-enters this handler + * synchronously and recurses until the stack overflows. + * + * The payload is stored in every case, which covers both a page that has not + * mounted its listener yet and one that is about to be navigated to. + */ +export function handleDeepLinkEvent( + detail: unknown, + isReady: boolean, + navigate: (path: string) => Promise = goto, +): void { + storeDeepLink(detail); + + // Not ready, or already where the payload is consented to: the mount reads + // the stored payload, so there is nothing left to do. + if (!isReady || window.location.pathname === "/scan-qr") { + return; + } + + navigate("/scan-qr").catch((error) => { + console.error("Error navigating to scan-qr:", error); + }); +} diff --git a/infrastructure/eid-wallet/src/routes/(app)/scan-qr/scanLogic.ts b/infrastructure/eid-wallet/src/routes/(app)/scan-qr/scanLogic.ts index ae9d4aec9..ad9b007c7 100644 --- a/infrastructure/eid-wallet/src/routes/(app)/scan-qr/scanLogic.ts +++ b/infrastructure/eid-wallet/src/routes/(app)/scan-qr/scanLogic.ts @@ -1,3 +1,4 @@ +import { clearDeepLink, peekDeepLink } from "$lib/stores/deepLink"; import { Format, type PermissionState, @@ -428,10 +429,7 @@ export function createScanLogic({ // Close the auth drawer first codeScannedDrawerOpen.set(false); - let deepLinkData = sessionStorage.getItem("deepLinkData"); - if (!deepLinkData) { - deepLinkData = sessionStorage.getItem("pendingDeepLink"); - } + const deepLinkData = peekDeepLink(); if (deepLinkData) { try { @@ -946,7 +944,7 @@ export function createScanLogic({ } showSigningSuccess.set(true); - const deepLinkData = sessionStorage.getItem("deepLinkData"); + const deepLinkData = peekDeepLink(); if (deepLinkData) { try { const data = JSON.parse(deepLinkData) as DeepLinkData; @@ -1666,10 +1664,7 @@ export function createScanLogic({ window.addEventListener("deepLinkAuth", authHandler); window.addEventListener("deepLinkSign", signHandler); - let deepLinkData = sessionStorage.getItem("deepLinkData"); - if (!deepLinkData) { - deepLinkData = sessionStorage.getItem("pendingDeepLink"); - } + const deepLinkData = peekDeepLink(); if (deepLinkData) { console.log("Found deep link data:", deepLinkData); @@ -1680,8 +1675,7 @@ export function createScanLogic({ } catch (error) { console.error("Error parsing deep link data:", error); } finally { - sessionStorage.removeItem("deepLinkData"); - sessionStorage.removeItem("pendingDeepLink"); + clearDeepLink(); } } else { console.log("No deep link data found, starting normal scanning"); diff --git a/infrastructure/eid-wallet/src/routes/(app)/settings/+page.svelte b/infrastructure/eid-wallet/src/routes/(app)/settings/+page.svelte index 52621e2c0..9de9dcdde 100644 --- a/infrastructure/eid-wallet/src/routes/(app)/settings/+page.svelte +++ b/infrastructure/eid-wallet/src/routes/(app)/settings/+page.svelte @@ -3,6 +3,7 @@ import { goto } from "$app/navigation"; import { SettingsNavigationBtn } from "$lib/fragments"; import type { GlobalState } from "$lib/global"; import { runtime } from "$lib/global/runtime.svelte"; +import { clearDeepLink } from "$lib/stores/deepLink"; import { getCurrentLanguage, subscribe as subscribeLanguage, @@ -90,6 +91,8 @@ async function performLogout() { } const newGlobalState = await globalState.reset(); setGlobalState(newGlobalState); + // goto("/") is an SPA navigation, so sessionStorage survives it. + clearDeepLink(); goto("/"); } diff --git a/infrastructure/eid-wallet/src/routes/(auth)/login/+page.svelte b/infrastructure/eid-wallet/src/routes/(auth)/login/+page.svelte index 724448e3b..f004670a6 100644 --- a/infrastructure/eid-wallet/src/routes/(auth)/login/+page.svelte +++ b/infrastructure/eid-wallet/src/routes/(auth)/login/+page.svelte @@ -2,21 +2,13 @@ import { goto } from "$app/navigation"; import { keyboardInset } from "$lib/actions/keyboardInset"; import type { GlobalState } from "$lib/global"; +import { hasDeepLink } from "$lib/stores/deepLink"; import { LoadingSheet, PinDots } from "$lib/ui"; import * as Button from "$lib/ui/Button"; import { continueAfterSuccessfulAuth } from "$lib/utils/postLogin"; -import { - type AuthOptions, - authenticate, - checkStatus, -} from "@tauri-apps/plugin-biometric"; import { getContext, onMount } from "svelte"; import StepHeader from "../onboarding/steps/StepHeader.svelte"; -// Splash sets this when it has already tried biometric over its own screen. -// /login then skips re-prompting and just shows the PIN UI. -const BIOMETRIC_ATTEMPTED_KEY = "biometricAttemptedOnSplash"; - let pin = $state(""); let isError = $state(false); let isPostAuthLoading = $state(false); @@ -36,17 +28,6 @@ function handleBackgroundClick(e: MouseEvent) { const getGlobalState = getContext<() => GlobalState | undefined>("globalState"); let globalState: GlobalState | undefined = $state(undefined); -const authOpts: AuthOptions = { - allowDeviceCredential: false, - cancelTitle: "Cancel", - // iOS - fallbackTitle: "Please enter your PIN", - // Android - title: "Login", - subtitle: "Please authenticate to continue", - confirmationRequired: true, -}; - async function clearPin() { if (isPostAuthLoading) return; pin = ""; @@ -83,6 +64,11 @@ $effect(() => { if (pin.length === 4) verifyAndAdvance(pin); }); +// This screen is the PIN fallback and deliberately never calls authenticate(). +// Biometrics are prompted from exactly one place, the splash, which routes +// here only once that prompt was declined, failed, or was unavailable. A +// second prompt site made the dialog's placement non-deterministic and let two +// post-auth routines race to consume one deep-link payload. onMount(async () => { // Root +layout creates globalState in its own onMount (which runs after // children). Poll until it's available — same pattern as (app)/+layout. @@ -100,36 +86,7 @@ onMount(async () => { } globalState = gs; - const pendingDeepLink = sessionStorage.getItem("pendingDeepLink"); - hasPendingDeepLink = !!pendingDeepLink; - - // If the splash already prompted biometric over its own screen, skip the - // retry here and let the user enter their PIN. The flag survives the - // route transition but is single-use. - const biometricHandledBySplash = - sessionStorage.getItem(BIOMETRIC_ATTEMPTED_KEY) === "true"; - if (biometricHandledBySplash) { - sessionStorage.removeItem(BIOMETRIC_ATTEMPTED_KEY); - return; - } - - // Try biometric first if available. - if ( - (await gs.securityController.biometricSupport) && - (await checkStatus()).isAvailable - ) { - try { - await authenticate( - "You must authenticate with PIN first", - authOpts, - ); - isPostAuthLoading = true; - await continueAfterSuccessfulAuth(gs); - } catch (e) { - console.error("Biometric authentication failed", e); - isPostAuthLoading = false; - } - } + hasPendingDeepLink = hasDeepLink(); }); diff --git a/infrastructure/eid-wallet/src/routes/(auth)/onboarding/+page.svelte b/infrastructure/eid-wallet/src/routes/(auth)/onboarding/+page.svelte index 293f9d0ad..65d1d2fd1 100644 --- a/infrastructure/eid-wallet/src/routes/(auth)/onboarding/+page.svelte +++ b/infrastructure/eid-wallet/src/routes/(auth)/onboarding/+page.svelte @@ -11,6 +11,7 @@ import { GlobalState } from "$lib/global"; import { pendingRecovery } from "$lib/stores/pendingRecovery"; import { ButtonAction, CopyableEName, LoadingSheet } from "$lib/ui"; import { capitalize, getCanonicalBindingDocString } from "$lib/utils"; +import { completeOnboarding } from "$lib/utils/postLogin"; import axios from "axios"; import { GraphQLClient } from "graphql-request"; import { getContext, onMount, tick } from "svelte"; @@ -264,9 +265,8 @@ const completeRecovery = async () => { ename: recovery.ename, }); pendingRecovery.set(null); - globalState.isOnboardingComplete = true; loadingPhase = null; - await goto("/main", { replaceState: true }); + await completeOnboarding(globalState); } catch (err) { console.error("[onboarding] recovery completion failed:", err); recoveryError = @@ -378,12 +378,10 @@ const handleNameComplete = async (enteredName: string) => { // Persist user + vault, then mark onboarding done. globalState.userController.user = { name: enteredName }; await globalState.vaultController.setVaultAndPersist({ uri, ename }); - globalState.isOnboardingComplete = true; - // Land on /main; the WelcomeTour there draws the animated lines // around the identity card (replaces the old /review + /e-passport). loadingPhase = null; - await goto("/main", { replaceState: true }); + await completeOnboarding(globalState); } catch (err) { console.error("Failed to provision eVault:", err); nameError = @@ -662,9 +660,8 @@ const handleProvision = async () => { uri: result.uri, ename: result.w3id, }); - globalState.isOnboardingComplete = true; loadingPhase = null; - await goto("/main", { replaceState: true }); + await completeOnboarding(globalState); } catch (err) { console.error("Provisioning failed:", err); error = @@ -804,9 +801,8 @@ const handleAnonymousSubmit = async () => { }; await globalState.vaultController.setVaultAndPersist({ uri, ename }); - globalState.isOnboardingComplete = true; loadingPhase = null; - await goto("/main", { replaceState: true }); + await completeOnboarding(globalState); } catch (err) { console.error("Anonymous provisioning failed:", err); error = diff --git a/infrastructure/eid-wallet/src/routes/+layout.svelte b/infrastructure/eid-wallet/src/routes/+layout.svelte index fd1271a54..45f892de4 100644 --- a/infrastructure/eid-wallet/src/routes/+layout.svelte +++ b/infrastructure/eid-wallet/src/routes/+layout.svelte @@ -8,6 +8,7 @@ import { GlobalState } from "$lib/global/state"; import { runtime } from "$lib/global/runtime.svelte"; import { swipedetect } from "$lib/utils"; +import { handleDeepLinkEvent, routeDeepLink } from "$lib/utils/routeDeepLink"; import { installTerminalConsoleBridge } from "$lib/utils/terminalConsole"; import { type Status, checkStatus } from "@tauri-apps/plugin-biometric"; @@ -143,41 +144,10 @@ onMount(async () => { "Global deep link event received:", customEvent.detail, ); - - if (!isAppReady || !globalState) { - console.log( - "App not ready, storing deep link data for later", - ); - sessionStorage.setItem( - "deepLinkData", - JSON.stringify(customEvent.detail), - ); - return; - } - - // Check if we're already on the scan page - if (window.location.pathname === "/scan-qr") { - // We're already on the scan page, dispatch the event directly - console.log( - "Already on scan page, dispatching event directly", - ); - const directEvent = new CustomEvent("deepLinkReceived", { - detail: customEvent.detail, - }); - window.dispatchEvent(directEvent); - } else { - // Store the deep link data and navigate to scan page - console.log( - "Not on scan page, storing data and navigating", - ); - sessionStorage.setItem( - "deepLinkData", - JSON.stringify(customEvent.detail), - ); - goto("/scan-qr").catch((error) => { - console.error("Error navigating to scan-qr:", error); - }); - } + handleDeepLinkEvent( + customEvent.detail, + isAppReady && !!globalState, + ); } catch (error) { console.error("Error in globalDeepLinkHandler:", error); } @@ -188,25 +158,6 @@ onMount(async () => { console.error("Failed to initialize deep link listener:", error); } - // Helper function to check if user is on an authenticated route. - // Routes under (app)/ are protected by the auth guard. Since SvelteKit - // route groups (parentheses) don't appear in the URL, enumerate the - // top-level segments here. Any new (app)// folder must be - // added below or its deep-links will redirect to /login. - function isAuthenticatedRoute(pathname: string): boolean { - const appRouteSegments = [ - "main", - "scan-qr", - "settings", - "personal", - "notifications", - "social-bindings", - "ePassport", - ]; - const firstSegment = pathname.split("/")[1] ?? ""; - return appRouteSegments.includes(firstSegment); - } - function handleDeepLink(urlString: string) { console.log("Deep link received:", urlString); @@ -224,19 +175,6 @@ onMount(async () => { Object.fromEntries(params.entries()), ); - // Check if we're already on the scan-qr page - const currentPath = window.location.pathname; - const isOnScanPage = currentPath === "/scan-qr"; - const isOnAuthenticatedRoute = isAuthenticatedRoute(currentPath); - console.log( - "Current path:", - currentPath, - "Is on scan page:", - isOnScanPage, - "Is on authenticated route:", - isOnAuthenticatedRoute, - ); - // For w3ds:// URLs, we need to check the hostname instead of pathname // w3ds://auth becomes hostname: "auth", pathname: "" const action = url.hostname || path; @@ -267,113 +205,7 @@ onMount(async () => { redirect: redirect, }; - // Check if user is authenticated by checking if they're on an authenticated route - const checkAuth = async () => { - // First check if user is on an authenticated route - // If not, they need to login first regardless of vault existence - if (!isOnAuthenticatedRoute) { - console.log( - "User not on authenticated route, storing deep link and redirecting to login", - ); - sessionStorage.setItem( - "pendingDeepLink", - JSON.stringify(deepLinkData), - ); - goto("/login").catch((error) => { - console.error( - "Error navigating to login:", - error, - ); - }); - return; - } - - try { - // Wait for globalState to be ready if it's not yet - if (!globalState) { - console.log( - "GlobalState not ready, waiting...", - ); - // Wait a bit and retry, or just redirect to login - let retries = 0; - const maxRetries = 10; - while (!globalState && retries < maxRetries) { - await new Promise((resolve) => - setTimeout(resolve, 100), - ); - retries++; - } - - if (!globalState) { - console.log( - "GlobalState still not ready, storing deep link and redirecting to login", - ); - sessionStorage.setItem( - "pendingDeepLink", - JSON.stringify(deepLinkData), - ); - goto("/login").catch((error) => { - console.error( - "Error navigating to login:", - error, - ); - }); - return; - } - } - - const vault = - await globalState.vaultController.vault; - if (vault) { - // User is authenticated, dispatch event and navigate to scan page - console.log( - "User authenticated, dispatching deep link event and navigating to scan-qr", - ); - - // Dispatch a custom event that the scan page can listen to - const deepLinkEvent = new CustomEvent( - "deepLinkReceived", - { - detail: deepLinkData, - }, - ); - window.dispatchEvent(deepLinkEvent); - - // Also store in sessionStorage as backup - sessionStorage.setItem( - "deepLinkData", - JSON.stringify(deepLinkData), - ); - - goto("/scan-qr").catch((error) => { - console.error( - "Error navigating to scan-qr:", - error, - ); - }); - return; - } - } catch (error) { - console.log( - "User not authenticated, redirecting to login", - error, - ); - } - - // User not authenticated, store deep link data and redirect to login - console.log( - "User not authenticated, storing deep link data and redirecting to login", - ); - sessionStorage.setItem( - "pendingDeepLink", - JSON.stringify(deepLinkData), - ); - goto("/login").catch((error) => { - console.error("Error navigating to login:", error); - }); - }; - - checkAuth(); + routeDeepLink(globalState, deepLinkData); } else { console.log("Missing required auth parameters"); } @@ -401,113 +233,7 @@ onMount(async () => { redirect_uri: redirectUri, }; - // Check if user is authenticated by checking if they're on an authenticated route - const checkAuth = async () => { - // First check if user is on an authenticated route - // If not, they need to login first regardless of vault existence - if (!isOnAuthenticatedRoute) { - console.log( - "User not on authenticated route, storing deep link and redirecting to login", - ); - sessionStorage.setItem( - "pendingDeepLink", - JSON.stringify(deepLinkData), - ); - goto("/login").catch((error) => { - console.error( - "Error navigating to login:", - error, - ); - }); - return; - } - - try { - // Wait for globalState to be ready if it's not yet - if (!globalState) { - console.log( - "GlobalState not ready, waiting...", - ); - // Wait a bit and retry, or just redirect to login - let retries = 0; - const maxRetries = 10; - while (!globalState && retries < maxRetries) { - await new Promise((resolve) => - setTimeout(resolve, 100), - ); - retries++; - } - - if (!globalState) { - console.log( - "GlobalState still not ready, storing deep link and redirecting to login", - ); - sessionStorage.setItem( - "pendingDeepLink", - JSON.stringify(deepLinkData), - ); - goto("/login").catch((error) => { - console.error( - "Error navigating to login:", - error, - ); - }); - return; - } - } - - const vault = - await globalState.vaultController.vault; - if (vault) { - // User is authenticated, dispatch event and navigate to scan page - console.log( - "User authenticated, dispatching deep link event and navigating to scan-qr", - ); - - // Dispatch a custom event that the scan page can listen to - const deepLinkEvent = new CustomEvent( - "deepLinkReceived", - { - detail: deepLinkData, - }, - ); - window.dispatchEvent(deepLinkEvent); - - // Also store in sessionStorage as backup - sessionStorage.setItem( - "deepLinkData", - JSON.stringify(deepLinkData), - ); - - goto("/scan-qr").catch((error) => { - console.error( - "Error navigating to scan-qr:", - error, - ); - }); - return; - } - } catch (error) { - console.log( - "User not authenticated, redirecting to login", - error, - ); - } - - // User not authenticated, store deep link data and redirect to login - console.log( - "User not authenticated, storing deep link data and redirecting to login", - ); - sessionStorage.setItem( - "pendingDeepLink", - JSON.stringify(deepLinkData), - ); - goto("/login").catch((error) => { - console.error("Error navigating to login:", error); - }); - }; - - checkAuth(); + routeDeepLink(globalState, deepLinkData); } else { console.log("Missing required signing parameters"); } @@ -524,113 +250,7 @@ onMount(async () => { pollId: pollId, }; - // Check if user is authenticated by checking if they're on an authenticated route - const checkAuth = async () => { - // First check if user is on an authenticated route - // If not, they need to login first regardless of vault existence - if (!isOnAuthenticatedRoute) { - console.log( - "User not on authenticated route, storing deep link and redirecting to login", - ); - sessionStorage.setItem( - "pendingDeepLink", - JSON.stringify(deepLinkData), - ); - goto("/login").catch((error) => { - console.error( - "Error navigating to login:", - error, - ); - }); - return; - } - - try { - // Wait for globalState to be ready if it's not yet - if (!globalState) { - console.log( - "GlobalState not ready, waiting...", - ); - // Wait a bit and retry, or just redirect to login - let retries = 0; - const maxRetries = 10; - while (!globalState && retries < maxRetries) { - await new Promise((resolve) => - setTimeout(resolve, 100), - ); - retries++; - } - - if (!globalState) { - console.log( - "GlobalState still not ready, storing deep link and redirecting to login", - ); - sessionStorage.setItem( - "pendingDeepLink", - JSON.stringify(deepLinkData), - ); - goto("/login").catch((error) => { - console.error( - "Error navigating to login:", - error, - ); - }); - return; - } - } - - const vault = - await globalState.vaultController.vault; - if (vault) { - // User is authenticated, dispatch event and navigate to scan page - console.log( - "User authenticated, dispatching deep link event and navigating to scan-qr for reveal", - ); - - // Dispatch a custom event that the scan page can listen to - const deepLinkEvent = new CustomEvent( - "deepLinkReceived", - { - detail: deepLinkData, - }, - ); - window.dispatchEvent(deepLinkEvent); - - // Also store in sessionStorage as backup - sessionStorage.setItem( - "deepLinkData", - JSON.stringify(deepLinkData), - ); - - goto("/scan-qr").catch((error) => { - console.error( - "Error navigating to scan-qr:", - error, - ); - }); - return; - } - } catch (error) { - console.log( - "User not authenticated, redirecting to login", - error, - ); - } - - // User not authenticated, store deep link data and redirect to login - console.log( - "User not authenticated, storing reveal deep link data and redirecting to login", - ); - sessionStorage.setItem( - "pendingDeepLink", - JSON.stringify(deepLinkData), - ); - goto("/login").catch((error) => { - console.error("Error navigating to login:", error); - }); - }; - - checkAuth(); + routeDeepLink(globalState, deepLinkData); } else { console.log("Missing required reveal parameters"); } diff --git a/infrastructure/eid-wallet/src/routes/+page.svelte b/infrastructure/eid-wallet/src/routes/+page.svelte index 481057663..e1967888e 100644 --- a/infrastructure/eid-wallet/src/routes/+page.svelte +++ b/infrastructure/eid-wallet/src/routes/+page.svelte @@ -9,9 +9,7 @@ import { authenticate, checkStatus, } from "@tauri-apps/plugin-biometric"; -import { getContext, onMount } from "svelte"; - -const BIOMETRIC_ATTEMPTED_KEY = "biometricAttemptedOnSplash"; +import { getContext, onDestroy, onMount } from "svelte"; const authOpts: AuthOptions = { allowDeviceCredential: false, @@ -47,6 +45,17 @@ async function handleRestoreDigitalSelf() { await goto("/recover"); } +// Unmounting a Svelte component does NOT cancel an async onMount parked on an +// await: the continuation resumes later and calls goto() from a screen the user +// left long ago. This routine sleeps 1.2s and then polls for global state, so +// it can still be suspended while the user authenticates by PIN on /login and +// /scan-qr opens the consent drawer. Waking then would navigate away and take +// that drawer with it. +let destroyed = false; +onDestroy(() => { + destroyed = true; +}); + onMount(async () => { if (skipIntro) { // Backward nav from /onboarding — already at state C, nothing to do. @@ -69,6 +78,8 @@ onMount(async () => { retries++; } + if (destroyed) return; + let onboardingComplete = false; let userExists = false; if (globalState) { @@ -93,24 +104,10 @@ onMount(async () => { return; } - // A third-party login deep link opened the app. The root layout has - // already stored it and redirected to /login, which runs its own - // biometric prompt. If we ALSO prompt here, two native authenticate() - // calls race on a cold start — the collision, plus a duplicate - // post-auth routine consuming the pending deep link, leaves the user - // on /main with the consent screen never shown. Defer to /login as the - // single authenticator. The layout writes pendingDeepLink synchronously - // and early, so it's reliably visible by the time we reach here. - if (sessionStorage.getItem("pendingDeepLink")) { - await goto("/login"); - return; - } - - // Fire biometric over the splash itself so the prompt isn't competing - // with the /login slide-in. On success we run the post-auth chores - // and route straight to /main (no /login flash). On cancel/fail we - // slide into /login with a sessionStorage flag so /login knows the - // biometric attempt already happened and skips re-prompting. + // The ONLY biometric prompt in the app. /login is the PIN fallback and + // never prompts, so the dialog always appears over this screen. On + // success we run the post-auth chores and route onward with no /login + // flash; on cancel, failure or unavailability we slide into /login. let biometricAvailable = false; try { biometricAvailable = @@ -120,22 +117,18 @@ onMount(async () => { } catch (error) { console.error("Biometric availability check failed:", error); } + if (destroyed) return; if (biometricAvailable && globalState) { - sessionStorage.setItem(BIOMETRIC_ATTEMPTED_KEY, "true"); try { await authenticate( "You must authenticate with PIN first", authOpts, ); - // Success — clear the flag (we won't reach /login at all) - // and run the shared post-auth routine. - sessionStorage.removeItem(BIOMETRIC_ATTEMPTED_KEY); await continueAfterSuccessfulAuth(globalState); return; } catch (e) { - // Cancel/fail. Leave the flag set so /login skips its own - // biometric retry, then slide into /login for PIN entry. + // Cancel/fail — slide into /login for PIN entry. console.warn("Biometric on splash failed", e); } }