diff --git a/infrastructure/eid-wallet/docs/deep-link-login-architecture.md b/infrastructure/eid-wallet/docs/deep-link-login-architecture.md new file mode 100644 index 000000000..099023ee9 --- /dev/null +++ b/infrastructure/eid-wallet/docs/deep-link-login-architecture.md @@ -0,0 +1,475 @@ +# Deep-link login: architecture before and after + +Scope: the `w3ds://` third-party login flow in the eID wallet. This document +describes the architecture that existed at merge-base `b29340c5`, the +architecture that exists now on `fix/eid-wallet-cold-start-deeplink-race`, how +data moves through each, and the reason behind every change. + +Audience: whoever maintains this next. The intent is that you can reason about +the flow without re-deriving it from the diff. + +--- + +## 1. What the flow has to do + +A third-party site (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=&redirect= +``` + +The wallet must: + +1. Receive the URL, whatever state the app is in (not running, backgrounded, + foregrounded, already on the scanner). +2. Make sure the user is authenticated (biometric, or PIN as fallback). +3. Show an **Approve / Decline** consent screen naming the requesting site. +4. On approve, POST the user's eName to the platform and open the platform in + the browser. +5. Show a confirmation card with an **Ok** button when the user comes back. + +Four properties make this harder than it looks, and every design decision below +traces back to one of them: + +- **P1 — Cold start is a race.** The URL arrives via an async plugin import + while the splash screen is independently deciding where to navigate. Either + can win. +- **P2 — Android delivers a cold-start URL twice.** Once through `getCurrent()` + and once through `onOpenUrl`. Both fire for a single user action. +- **P3 — `openUrl` can destroy the webview.** Handing off to the browser + backgrounds the app; Android may reload the webview, wiping `sessionStorage`. + The Activity is `singleTask`, so the deep-link plugin then **replays the + original intent** into a fresh webview that has no memory of it. +- **P4 — The URL is not unique per launch.** Platforms mint one `session` per + *offer*, and the login QR only refreshes every 60s. A user retrying a login + presents a byte-identical URL. "Seen this URL before" therefore cannot mean + "ignore forever". + +--- + +## 2. Architecture BEFORE (`b29340c5`) + +### 2.1 Components + +| Component | Responsibility | +|---|---| +| `routes/+layout.svelte` | Registers `onOpenUrl` + `getCurrent`, parses URL, decides route | +| `routes/+page.svelte` (splash) | Intro animation, then biometric prompt for returning users | +| `routes/(auth)/login/+page.svelte` | PIN pad **and its own biometric prompt** | +| `lib/utils/postLogin.ts` | Shared post-auth chores, then route to deep link or `/main` | +| `routes/(app)/+layout.svelte` | Auth guard: vault must exist or bounce to `/login` | +| `routes/(app)/scan-qr/scanLogic.ts` | Consent drawers, approve/decline, camera | + +State lived in three raw `sessionStorage` keys, written inline at each call +site with no shared module: + +- `pendingDeepLink` — payload parked because the user is not authenticated yet +- `deepLinkData` — payload ready for `/scan-qr` to consume +- `biometricAttemptedOnSplash` — handshake so `/login` could skip re-prompting + +### 2.2 Data flow + +```mermaid +flowchart TD + A["Android intent w3ds://"] --> B["root +layout onMount
onOpenUrl / getCurrent"] + B --> C["parse URL"] + C --> D{"isAuthenticatedRoute(path)
OR globalState ready?"} + D -- "no" --> E["sessionStorage: pendingDeepLink"] + E --> F["goto /login"] + D -- "yes" --> G["sessionStorage: deepLinkData"] + G --> H["dispatch deepLinkReceived"] + G --> I["goto /scan-qr"] + + S["splash +page onMount"] --> S1["800ms + 400ms intro"] + S1 --> S2["poll globalState, up to 5s"] + S2 --> S3{"pendingDeepLink set?"} + S3 -- "yes" --> F + S3 -- "no" --> S4["authenticate() on splash"] + S4 -- "ok" --> P["continueAfterSuccessfulAuth"] + S4 -- "fail" --> F + + F --> L["/login onMount"] + L --> L1{"biometricAttemptedOnSplash?"} + L1 -- "no" --> L2["authenticate() AGAIN here"] + L1 -- "yes" --> L3["PIN pad only"] + L2 -- "ok" --> P + L3 -- "pin ok" --> P + + P --> P1{"pendingDeepLink?"} + P1 -- "yes" --> P2["copy to deepLinkData"] --> I + P1 -- "no" --> P3["goto /main"] + + I --> M["scanLogic onMount"] + M --> M1{"deepLinkData or pendingDeepLink?"} + M1 -- "yes" --> M2["open consent drawer"] + M1 -- "no" --> M3["startScan camera"] +``` + +### 2.3 How it worked, and where it broke + +On the **warm path** it worked fine. App already open and authenticated: the +handler saw an authenticated route, wrote `deepLinkData`, dispatched the event, +and `/scan-qr` opened the drawer. That path was never broken and is essentially +unchanged today. + +The **cold path** was where it failed, and the failure was a genuine race +(P1). Two independent `onMount` routines: + +- The layout imports the deep-link plugin asynchronously, then discovers the URL. +- The splash sleeps 1.2s, polls for `globalState`, then prompts biometrics. + +The splash's guard against the collision was to check `pendingDeepLink` and +divert to `/login`, deferring to `/login` as the single authenticator. **That +guard depends on the layout winning the race.** With fast biometrics — a user +whose finger is already on the sensor — the ordering inverted: + +``` +splash: reads pendingDeepLink -> empty (layout still importing) +splash: authenticate() -> success in ~200ms +splash: continueAfterSuccessfulAuth -> no pendingDeepLink -> goto /main +layout: URL finally arrives, writes pendingDeepLink, goto /login +(app) guard / login: user is already authenticated -> /main +result: payload parked forever, consent screen never appears +``` + +That is the original bug. The payload is written *after* the only code that +would have read it. + +### 2.4 What the original did NOT have + +Worth stating plainly, because it explains why the branch grew so long: + +- **No dedupe of any kind.** P2's double delivery was handled accidentally: the + second delivery overwrote `deepLinkData` with an identical payload, and + `/scan-qr` was idempotent about opening an already-open drawer. +- **No durable storage.** Nothing survived the P3 webview teardown. Coming back + from the browser showed a bare scanner instead of a confirmation card. +- **No concept of "this login is finished".** +- **No suppression window**, so the 30s window did not exist, and declining + recorded nothing. A declined login could always be retried immediately. + +That last point matters: the retry-after-decline bug was **introduced by this +branch**, not fixed by it. See §4.8. + +--- + +## 3. Architecture AFTER + +### 3.1 The central change: one module owns the protocol + +All deep-link state moved into `lib/utils/deepLinkFlow.ts` (~560 lines, +heavily commented, 56 unit tests). Call sites no longer touch `sessionStorage` +directly. The module owns which store each fact lives in, and that distinction +is the core of the design: + +| Store | Survives | Holds | +|---|---|---| +| `sessionStorage` | SPA navigation only. Wiped by webview teardown. | `pendingDeepLink`, `deepLinkData`, `walletAuthenticated`, `walletAuthInFlight`, `splashOwnsAuthPrompt`, `deepLinkLastUrl` | +| `localStorage` | Webview teardown and Activity restart | `deepLinkHandledUrl`, `deepLinkHandledAt`, `deepLinkCompleted`, `deepLinkAcknowledgedAt` | + +The rule: **only facts needed to survive the P3 restart are durable.** +Emphatically *not* `walletAuthenticated` — making that durable would let a deep +link arriving after a full app kill skip authentication entirely. Being +forgotten on relaunch is the property that makes it safe. + +### 3.2 Data flow now + +```mermaid +flowchart TD + A["Android intent w3ds://"] --> B["root layout: onOpenUrl / getCurrent
started FIRST, runs concurrently"] + B --> DUP{"isDuplicateDelivery(url)?"} + DUP -- "yes" --> X["ignore"] + DUP -- "no" --> C["parse payload"] + C --> D{"authenticated route
OR isWalletAuthenticated()?"} + + D -- "no" --> E["markDeepLinkPending(payload)"] + E --> F{"shouldRedirectToLogin()
promptInFlight? splashOwns?"} + F -- "someone owns the prompt" --> W["DEFER: owner will route"] + F -- "nobody owns it" --> G["goto /login (PIN only)"] + + D -- "yes" --> H["markDeepLinkReady + dispatch event"] + H --> I["goto /scan-qr"] + + S["splash: claimSplashAuthOwnership()
SYNCHRONOUS at component init"] --> S1["intro + globalState poll"] + S1 --> S2["runReturningUserAuth"] + S2 --> S3["await initialDeepLinkReady"] + S3 --> S4["beginAuthPrompt + authenticate()"] + S4 -- "ok" --> P["continueAfterSuccessfulAuth"] + S4 -- "fail" --> G + + P --> P0["markWalletAuthenticated() BEFORE any await"] + P0 --> P1["async chores fire-and-forget"] + P1 --> P2["promotePendingDeepLink()
then endAuthPrompt()
then goto — all synchronous"] + P2 --> I + + I --> M["scanLogic onMount"] + M --> M0["await initialDeepLinkReady (3s cap)"] + M0 --> M1{"payload present?"} + M1 -- "yes" --> M2["consent drawer"] + M1 -- "no" --> M4{"takeCompletedDeepLink()?"} + M4 -- "yes" --> M5["restore confirmation card"] + M4 -- "no" --> M6{"wasDeepLinkJustAcknowledged()?"} + M6 -- "yes" --> M7["goto /main, no camera"] + M6 -- "no" --> M8["startScan camera"] + + M2 --> AP["Approve"] + M2 --> DEC["Decline"] + AP --> AP1["markDeepLinkHandled() DURABLE
markDeepLinkCompleted()"] + AP1 --> AP2["openUrl -> browser -> Activity restart"] + AP2 --> M + DEC --> DEC1["markDeepLinkHandled(undefined, false)
session-scoped only"] + DEC1 --> DEC2["goto /main; retry works"] +``` + +### 3.3 The three invariants everything else follows from + +**I1 — Exactly one screen prompts for biometrics: the splash.** +`/login` is now the PIN fallback and never calls `authenticate()`. Two prompt +sites made the system dialog's backdrop non-deterministic and let two post-auth +routines race to consume one payload. + +**I2 — Ownership of the auth prompt is an explicit claim, never inferred.** +Two flags, both in `sessionStorage`: +- `walletAuthInFlight` — a native prompt is on screen right now + (`beginAuthPrompt` / `endAuthPrompt`) +- `splashOwnsAuthPrompt` — the splash is mounted and will prompt, or is + mid-handover (`claimSplashAuthOwnership` / `releaseSplashAuthOwnership`) + +`shouldRedirectToLogin()` returns false if either is set. The handler parks the +payload and lets the owner route. + +**I3 — The handover from auth to consent is synchronous.** +In `continueAfterSuccessfulAuth`, everything from `promotePendingDeepLink()` +through `endAuthPrompt()` to `goto()` runs with no `await` between. Any await in +that window is a gap where a re-delivered URL sees no owner and fires a +competing navigation. + +--- + +## 4. Every change, and why + +### 4.1 Wait for deep-link discovery before deciding (`ac69802c`, `6fb30e58`) + +**Problem:** the original bug (§2.3) — the splash decided "no deep link" before +the layout had finished discovering one. + +**Change:** the layout exposes `initialDeepLinkReady`, a promise resolved once +`getCurrent()` and listener registration have both completed. It is provided via +Svelte context. The splash awaits it before choosing a destination; `/scan-qr` +awaits it too, capped at 3s so a plugin failure cannot leave a blank page. + +**Why a promise rather than a flag:** the splash needs to *wait*, not poll. A +flag would reintroduce the same race at a different granularity. + +### 4.2 Single biometric prompt site (`504903d7`) + +**Problem:** `/login` and the splash each prompted from their own `onMount`. +Whichever won decided whether the dialog appeared over the purple splash or a +half-painted PIN pad. Worse, both could run `continueAfterSuccessfulAuth`, and +two post-auth routines consuming one payload is how it got dropped. + +**Change:** `/login` no longer calls `authenticate()`. The splash no longer +diverts a deep-link launch to `/login`. The routing decision was extracted into +`shouldRedirectToLogin()` so it is unit-testable. Deleted the now-dead +`biometricAttemptedOnSplash` handshake. + +**Trade-off, stated honestly:** a user who cancels biometrics gets the PIN pad +with no way to retry biometrics without relaunching. That was true before for +deep-link launches; it is now true for all launches. + +### 4.3 Ownership as an explicit claim (`b9eb573b`, `c8cc4487`) + +**Problem:** `shouldRedirectToLogin` originally inferred "the splash owns the +prompt" from `currentPath === "/"`. Unsound in both directions. After the +handover released the bracket, a re-delivered URL still saw `"/"` until the +`goto` landed, so the handler deferred to an owner that no longer existed and +the payload was parked forever. + +**Change (`b9eb573b`):** replaced the path inference with the explicit +`splashOwnsAuthPrompt` claim, and removed the path parameter entirely. + +**That was not enough (`c8cc4487`).** The reported symptom after `b9eb573b` was +that the PIN pad sometimes appeared *instead of* biometrics, which was +diagnostic: the claim ran ~1.2s after mount, after the intro and the globalState +poll, but the deep link is delivered from the layout's `onMount` inside that +window. The handler saw no owner, did `goto("/login")`, and unmounted the splash +before it could prompt. + +Fixed by claiming **synchronously at component init**, before any await, with +release on every non-authenticating exit plus `onDestroy`. +`runReturningUserAuth()` was extracted so there is a single release point. + +**Principle:** a claim that is established after an await is not a claim, it is +a race with extra steps. + +### 4.4 Stale continuation guard (`d95c8398`, `0ccaae0c`, `a0b37b0f`) + +**Problem:** unmounting a Svelte component does not cancel an `onMount` parked +on an await. The splash's continuation would resume long after the user had +left and call `goto()`, tearing down an open consent drawer. + +**Change:** `shouldAbortStaleContinuation(destroyed, authenticatedAtStart)`. + +**The subtlety:** the first version tested `isWalletAuthenticated()` alone. That +broke `/login`, because arriving there already-authenticated (exactly what a +deep-link flow does) made a freshly mounted screen classify itself as stale, so +it returned before prompting. The question is "was this routine *superseded* +while it waited?", which is not "is the session authenticated?". Callers now +snapshot auth state at start and pass it back; only a *transition* counts. + +### 4.5 Dedupe with a bounded window (`78c9ce82`, `a380cb09`, `60cc2941`) + +**Problem:** P2 — Android delivers cold-start URLs through both `getCurrent()` +and `onOpenUrl`. + +**Change:** `isDuplicateDelivery(url)` compares against the last-seen URL. + +**Why bounded (`a380cb09`):** the first version was a permanent blacklist, which +collided with P4. Since platforms reuse one `session` per offer, a user retrying +a pending login presents an identical URL, and it was silently swallowed — the +approval screen simply never appeared again. Hence `REPLAY_WINDOW_MS = 30_000`: +long enough to cover the Activity restart, short enough that the same link +later reads as the new request it is. + +### 4.6 Surviving the Activity restart (`c9a7af2a`, `c3c80b7f`, `687807e0`) + +**Problem:** P3. Approving calls `openUrl`; Android reloads the backgrounded +webview and wipes `sessionStorage`; the plugin replays the original intent into +a fresh webview. Two symptoms: the finished login was re-offered, and the +confirmation card was gone. + +**Change:** dedupe markers moved to `localStorage`; `markDeepLinkCompleted()` +stores platform, hostname and redirect durably so the rebuilt webview can +reconstruct the confirmation card (`takeCompletedDeepLink()`). + +The hostname was added in `687807e0` because the app icon is resolved from it, +so restoring the name alone rendered the card with a blank logo. + +### 4.7 Acknowledgement, so Ok does not open the camera (`6feacb49`) + +**Problem:** tapping Ok before the Activity restart landed left no trace. The +rebuilt webview found no payload and started the camera — a scanner the user +never asked for. + +**Change:** an `acknowledged` flag on `takeCompletedDeepLink()`, a durable +`ACKNOWLEDGED_KEY`, and `wasDeepLinkJustAcknowledged()`. `/scan-qr` returns to +`/main` instead of scanning. `clearDeepLinkAcknowledged()` is called from the root +layout's `onNavigate` when the user deliberately taps Scan. A webview rebuild is +a fresh page load and never fires that hook, so clearing there cannot mask the +case the marker guards. + +**Load-bearing detail:** the acknowledgement is written *before* the single-use +read. Mutation testing proved that ordering matters — swapping them lets the +read consume the record before the flag is stamped. + +### 4.8 Decline must not block a retry (`70c9bdb9`) + +**Problem, and it was mine.** Declining recorded the URL as handled *durably*, +so retrying the same link within 30s was dropped as a duplicate and the user +landed on `/main` with no consent screen — the original symptom, different +cause. In the original code decline recorded nothing at all, so retry always +worked (§2.4). + +**Root cause:** I applied approve-shaped reasoning to decline without checking +the premise. The durable marker exists solely to survive the Activity restart +that `openUrl` causes. **Decline never calls `openUrl`**, so no restart is +coming and there is nothing to survive. The marker outlived the decision. + +**Change:** `markDeepLinkHandled(urlString?, durable = true)`. Decline and the +in-app QR-scan POST path pass `false` — session-scoped only, which still +collapses the P2 double delivery within the current webview. Approve stays +durable. + +### 4.9 Refresh the window on Ok (`d45489d4`) + +**Problem:** a duplicate pending-login prompt after a slow browser round-trip. + +**Investigation:** `HANDLED_AT` is stamped at approval — before `openUrl`, +before time spent on the platform, before the Activity restart. A slow +round-trip exhausted the 30s window, so the replay was no longer recognised as +one. A probe against the real module confirmed suppression works *within* the +window, which is what pointed at expiry. + +**Change:** tapping Ok refreshes `HANDLED_AT`, guarded so it fires only when +`acknowledged=true` and a handled URL already exists. + +### 4.10 Startup latency (`12983713`) + +**Problem:** user-reported slowness before biometrics. + +**Finding:** auditing the whole diff against `b29340c5` showed the 800ms/400ms +intro and the polling loops were all pre-existing. What I had added was +`await initialDeepLinkReady` at the end of a serial chain: `checkStatus()` → +`GlobalState.create()` → `import(deep-link)` → `onOpenUrl`/`getCurrent()`. + +**Change:** deep-link discovery starts first and runs concurrently. +`GlobalState.create()` no longer waits on `checkStatus()` — verified that +`runtime.biometry` is written but never read anywhere. + +--- + +## 5. Comparison + +| Concern | Before | After | +|---|---|---| +| Deep-link state | 3 raw keys, inline at call sites | `deepLinkFlow.ts`, 56 tests | +| Biometric prompt sites | 2 (splash + `/login`), racing | 1 (splash) | +| Prompt ownership | Inferred from pathname | Explicit claim, sync at init | +| Cold-start ordering | Unsynchronised race | `initialDeepLinkReady` promise | +| Double delivery (P2) | Accidentally idempotent | Explicit, 30s bounded | +| Webview teardown (P3) | Not handled; card lost | Durable markers + restore | +| Retry same URL (P4) | Worked (nothing recorded) | Works (window + non-durable decline) | +| Post-`openUrl` return | Bare scanner | Confirmation card, or `/main` if acked | + +--- + +## 6. Honest limitations + +**Test coverage does not reach the call sites.** vitest here is node-only and +mounts no Svelte components. The 56 tests pin the *semantics* of +`deepLinkFlow.ts` — mutating it kills tests reliably. But flipping the +production decline call in `+page.svelte` back to `markDeepLinkHandled()` +**passes the entire suite**. That one line is verified by reading it, not by a +test. The same applies to every other call site in `.svelte` files. + +**Device testing is the only real proof.** `pnpm build:apk`. The races here are +between native plugin delivery and Svelte lifecycle, and neither exists in node. + +**The 30s window is a heuristic.** It is not derived from a measured +distribution of Activity restart latency. §4.9 exists because it was too short +for a slow round-trip. If the duplicate-prompt symptom returns, the window is +the first suspect, and the right fix is probably to stop relying on wall-clock +time and key the marker to something restart-scoped instead. + +**Known-unfixed, deliberately left alone:** `(auth)/+layout.svelte` references +`bg-background` and `text-foreground-muted`, neither defined in the `@theme` +block in `app.css` (verified: 0 rules in the compiled output), so the loading +placeholder is transparent. Also the eVault 429 recovery-screen error. + +**This branch is 27 commits for one bug.** Most of the later ones fix +regressions introduced by earlier ones. The pattern in the failures was +consistent: asserting a timing or causal relationship without verifying it — +inferring ownership from a pathname, claiming ownership after an await, +assuming decline needed the same durability as approve. Checking `b29340c5` +first would have caught several of them immediately, since in most cases the +original code simply did not do the thing I was "preserving". + +--- + +## 7. Reference + +**Diagnostics in logcat:** +- `Deep link routing:` — prints `authPromptInFlight` and `splashOwnsAuth` +- `Deferring navigation: the auth prompt owner will route` +- `Duplicate deep link delivery ignored:` +- `Restoring post-login confirmation after app restart` +- `Deep link already acknowledged, returning to main` + +**Key files:** +- `src/lib/utils/deepLinkFlow.ts` — protocol, storage, all decisions +- `src/lib/utils/deepLinkFlow.spec.ts` — 56 tests +- `src/routes/+layout.svelte` — delivery, dedupe, routing +- `src/routes/+page.svelte` — splash, the only biometric prompt +- `src/lib/utils/postLogin.ts` — the synchronous handover +- `src/routes/(app)/scan-qr/scanLogic.ts` — consent, approve/decline, restore 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..6dea3982b 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.0; DEVELOPMENT_TEAM = M49C8XS835; ENABLE_BITCODE = NO; "EXCLUDED_ARCHS[sdk=iphoneos*]" = x86_64; @@ -415,7 +415,7 @@ "$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)", "$(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)", ); - MARKETING_VERSION = 1.0.1; + MARKETING_VERSION = 1.1.1; PRODUCT_BUNDLE_IDENTIFIER = foundation.metastate.eid-wallet; PRODUCT_NAME = "eID for W3DS"; SDKROOT = iphoneos; @@ -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.0; DEVELOPMENT_TEAM = M49C8XS835; ENABLE_BITCODE = NO; "EXCLUDED_ARCHS[sdk=iphoneos*]" = x86_64; @@ -463,7 +463,7 @@ "$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)", "$(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)", ); - MARKETING_VERSION = 1.0.1; + MARKETING_VERSION = 1.1.1; PRODUCT_BUNDLE_IDENTIFIER = foundation.metastate.eid-wallet; PRODUCT_NAME = "eID for W3DS"; SDKROOT = iphoneos; 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..74f93a253 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.0 LSRequiresIPhoneOS NSAppTransportSecurity 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..b457ff127 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": 30 }, "icon": [ "icons/32x32.png", diff --git a/infrastructure/eid-wallet/src/lib/utils/deepLinkFlow.spec.ts b/infrastructure/eid-wallet/src/lib/utils/deepLinkFlow.spec.ts new file mode 100644 index 000000000..8d50b9350 --- /dev/null +++ b/infrastructure/eid-wallet/src/lib/utils/deepLinkFlow.spec.ts @@ -0,0 +1,840 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + beginAuthPrompt, + claimSplashAuthOwnership, + clearDeepLinkAcknowledged, + clearDeepLinkFlow, + endAuthPrompt, + isAuthPromptInFlight, + isDeepLinkFlowActive, + isDuplicateDelivery, + isWalletAuthenticated, + markDeepLinkCompleted, + markDeepLinkHandled, + markDeepLinkPending, + markDeepLinkReady, + markWalletAuthenticated, + peekDeepLinkPayload, + promotePendingDeepLink, + releaseSplashAuthOwnership, + resetAuthSession, + shouldAbortStaleContinuation, + shouldRedirectToLogin, + takeCompletedDeepLink, + wasDeepLinkJustAcknowledged, +} from "./deepLinkFlow"; + +/** + * Minimal sessionStorage stand-in — the module is deliberately storage-backed + * so that state survives the full-page navigations the wallet performs. + */ +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, value); + } +} + +const AUTH_PAYLOAD = { + type: "auth", + session: "sess-1", + platform: "example", + redirect: "https://example.com/cb", +}; + +beforeEach(() => { + vi.stubGlobal("sessionStorage", new MemoryStorage()); + vi.stubGlobal("localStorage", new MemoryStorage()); +}); + +/** + * Simulate Android reloading the webview while the app sits in the background + * (which happens when a deep-link login hands off to the browser via openUrl). + * sessionStorage does not survive that; localStorage does. + */ +/** + * Mirror of handleAuthDrawerDecline's completion call. Declining keeps the + * user in the app, so it must NOT write the durable cross-webview marker. + * Kept here as a single definition so that flipping the production call back + * to a durable mark fails these tests rather than passing silently. + */ +function declineDeepLink() { + markDeepLinkHandled(undefined, false); +} + +function reloadWebview() { + vi.stubGlobal("sessionStorage", new MemoryStorage()); +} + +describe("deep link flow state", () => { + it("reports an active flow for a payload awaiting authentication", () => { + expect(isDeepLinkFlowActive()).toBe(false); + markDeepLinkPending(AUTH_PAYLOAD); + expect(isDeepLinkFlowActive()).toBe(true); + expect(JSON.parse(peekDeepLinkPayload() as string)).toEqual( + AUTH_PAYLOAD, + ); + }); + + it("never hides the payload during the pending -> ready handover", () => { + // The promotion renames the key the payload lives under. It must not + // be observable as "no deep link" at any point in between, or code + // running concurrently with authentication concludes the request has + // gone away. This holds because promote writes the new key before + // removing the old one, and readers check both. + markDeepLinkPending(AUTH_PAYLOAD); + + expect(promotePendingDeepLink()).toBe(true); + + expect(sessionStorage.getItem("pendingDeepLink")).toBeNull(); + expect(sessionStorage.getItem("deepLinkData")).not.toBeNull(); + expect(isDeepLinkFlowActive()).toBe(true); + expect(JSON.parse(peekDeepLinkPayload() as string)).toEqual( + AUTH_PAYLOAD, + ); + }); + + it("reports no active flow once the payload has been consumed", () => { + // Regression: a sticky "flow active" marker used to outlive the + // payload, so a login the user had already completed kept being + // offered back to them on /login as a pending request. + markDeepLinkPending(AUTH_PAYLOAD); + promotePendingDeepLink(); + expect(isDeepLinkFlowActive()).toBe(true); + + clearDeepLinkFlow(); + + expect(isDeepLinkFlowActive()).toBe(false); + expect(peekDeepLinkPayload()).toBeNull(); + expect(sessionStorage.getItem("deepLinkFlowActive")).toBeNull(); + }); + + it("treats a payload that arrives post-authentication as active", () => { + markDeepLinkReady(AUTH_PAYLOAD); + expect(isDeepLinkFlowActive()).toBe(true); + expect(sessionStorage.getItem("pendingDeepLink")).toBeNull(); + }); + + it("has nothing to promote when no payload is pending", () => { + expect(promotePendingDeepLink()).toBe(false); + expect(isDeepLinkFlowActive()).toBe(false); + }); + + it("clears every key once the consent drawer has consumed the payload", () => { + markDeepLinkPending(AUTH_PAYLOAD); + promotePendingDeepLink(); + + clearDeepLinkFlow(); + + expect(isDeepLinkFlowActive()).toBe(false); + expect(peekDeepLinkPayload()).toBeNull(); + }); + + it("survives a pending payload being overwritten by a newer one", () => { + markDeepLinkPending(AUTH_PAYLOAD); + const newer = { ...AUTH_PAYLOAD, session: "sess-2" }; + markDeepLinkPending(newer); + + promotePendingDeepLink(); + expect(JSON.parse(peekDeepLinkPayload() as string)).toEqual(newer); + }); +}); + +describe("authentication signals", () => { + it("records that the user got through authentication", () => { + expect(isWalletAuthenticated()).toBe(false); + markWalletAuthenticated(); + expect(isWalletAuthenticated()).toBe(true); + }); + + it("brackets an in-flight prompt so the layout defers navigation", () => { + expect(isAuthPromptInFlight()).toBe(false); + beginAuthPrompt(); + expect(isAuthPromptInFlight()).toBe(true); + endAuthPrompt(); + expect(isAuthPromptInFlight()).toBe(false); + }); + + it("is safe to end a prompt that was never begun", () => { + expect(() => endAuthPrompt()).not.toThrow(); + expect(isAuthPromptInFlight()).toBe(false); + }); +}); + +describe("cold-start orderings", () => { + /** + * Each case walks one interleaving of the four concurrent actors and + * asserts the user ends up at the consent screen. The fix is only correct + * if EVERY ordering lands there — the previous implementations worked for + * the slow ordering and dropped the payload on the fast one. + */ + + it("URL arrives, then the user authenticates (slow biometric)", () => { + markDeepLinkPending(AUTH_PAYLOAD); + + // The splash stays mounted and owns the prompt even when a deep link + // is pending, so that a deep-link launch still gets biometrics. + expect(isDeepLinkFlowActive()).toBe(true); + claimSplashAuthOwnership(); + expect(shouldRedirectToLogin()).toBe(false); + + beginAuthPrompt(); + endAuthPrompt(); + markWalletAuthenticated(); + promotePendingDeepLink(); + + expect(peekDeepLinkPayload()).not.toBeNull(); + }); + + it("URL arrives while the biometric prompt is already on screen (fast auth)", () => { + // Splash starts its prompt before the cold-start URL is delivered. + beginAuthPrompt(); + + // The URL lands mid-prompt. The layout must park it and NOT navigate, + // because the post-auth routine owns routing from here. + markDeepLinkPending(AUTH_PAYLOAD); + expect(isAuthPromptInFlight()).toBe(true); + + // Auth succeeds; the post-auth routine collects the parked payload. + endAuthPrompt(); + markWalletAuthenticated(); + expect(promotePendingDeepLink()).toBe(true); + expect(peekDeepLinkPayload()).not.toBeNull(); + }); + + it("URL arrives after authentication already completed", () => { + beginAuthPrompt(); + endAuthPrompt(); + markWalletAuthenticated(); + + // Nothing was pending at auth time, so the post-auth routine routed to + // /main. The late URL must still reach the consent screen: the layout + // sees an authenticated session and marks the payload ready directly. + expect(promotePendingDeepLink()).toBe(false); + + markDeepLinkReady(AUTH_PAYLOAD); + expect(isWalletAuthenticated()).toBe(true); + expect(peekDeepLinkPayload()).not.toBeNull(); + }); +}); + +describe("duplicate delivery guard", () => { + it("collapses the double delivery of one cold-start URL", () => { + const url = "w3ds://auth?session=sess-1&platform=example"; + expect(isDuplicateDelivery(url)).toBe(false); + expect(isDuplicateDelivery(url)).toBe(true); + }); + + it("does not suppress a different URL", () => { + expect(isDuplicateDelivery("w3ds://auth?session=a")).toBe(false); + expect(isDuplicateDelivery("w3ds://auth?session=b")).toBe(false); + }); + + it("keeps suppressing a handled URL after the flow is consumed", () => { + // Regression: releasing the guard on clearDeepLinkFlow re-armed the + // loop it exists to stop. /scan-qr consumes the payload and clears the + // flow, and Android (singleTask) keeps replaying the original intent + // from getCurrent(), so a released guard let the same login restart + // over and over. + const url = "w3ds://auth?session=sess-1&platform=example"; + expect(isDuplicateDelivery(url)).toBe(false); + expect(isDuplicateDelivery(url)).toBe(true); + + markDeepLinkHandled(); + + expect(isDuplicateDelivery(url)).toBe(true); + }); + + it("suppresses a replay even if the flow is cleared after one delivery", () => { + // The duplicate may arrive AFTER /scan-qr has already consumed the + // payload. Clearing must therefore remember the URL as handled rather + // than forget it, or the replay restarts the login. + const url = "w3ds://auth?session=sess-1&platform=example"; + expect(isDuplicateDelivery(url)).toBe(false); + + markDeepLinkHandled(); + + expect(isDuplicateDelivery(url)).toBe(true); + }); + + it("forgets handled URLs on logout so the same link works again", () => { + // Logging out and back in with a link the user was previously sent is + // a legitimate new request, and the session is torn down anyway. + const url = "w3ds://auth?session=sess-1&platform=example"; + expect(isDuplicateDelivery(url)).toBe(false); + markDeepLinkHandled(); + expect(isDuplicateDelivery(url)).toBe(true); + + resetAuthSession(); + + expect(isDuplicateDelivery(url)).toBe(false); + }); + + it("still accepts a genuinely new request after one is consumed", () => { + // The guard keys on the whole URL, and the platform mints a fresh + // `session` uuid per request, so a real second login is never + // mistaken for a replay of the first. + const first = "w3ds://auth?session=sess-1&platform=example"; + const second = "w3ds://auth?session=sess-2&platform=example"; + + expect(isDuplicateDelivery(first)).toBe(false); + markDeepLinkHandled(); + + expect(isDuplicateDelivery(second)).toBe(false); + }); + + it("still suppresses a replay after the webview reloads in the background", () => { + // The warm-resume bug: completing a deep-link login sends the user out + // to the browser via openUrl, and Android may reload the backgrounded + // webview. The Activity is singleTask and keeps replaying the original + // intent from getCurrent(), so a marker that died with the webview let + // the finished login start all over again — ending on the PIN screen. + const url = "w3ds://auth?session=sess-1&platform=example"; + expect(isDuplicateDelivery(url)).toBe(false); + markDeepLinkHandled(); + + reloadWebview(); + + expect(isDuplicateDelivery(url)).toBe(true); + }); + + it("honours the same URL again once the replay window has passed", () => { + // Regression, and the reason deep-link login stopped working entirely: + // handled URLs used to be remembered FOREVER, on the false assumption + // that every request carries a unique session. A session belongs to an + // offer and the same offer URI is reused while its QR is displayed, so + // a permanent marker blacklisted real retries and the approval screen + // never appeared again. + vi.useFakeTimers(); + try { + const url = "w3ds://auth?session=sess-1&platform=example"; + expect(isDuplicateDelivery(url)).toBe(false); + markDeepLinkHandled(); + reloadWebview(); + expect(isDuplicateDelivery(url)).toBe(true); + + vi.advanceTimersByTime(31_000); + reloadWebview(); + + expect(isDuplicateDelivery(url)).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + + it("lets the user retry a login they just declined", () => { + // Reported: decline the first Approve/Decline prompt, then open the + // same login link again — and the consent screen never appeared, the + // app just went to /main. + // + // Decline recorded the URL as handled DURABLY, which is the marker + // built to survive an Activity restart. But declining keeps the user + // inside the app; no restart is coming. The durable marker simply + // outlived the decision and swallowed the retry, because platforms + // reuse one `session` per offer so the retry URL is identical. + const url = "w3ds://auth?session=21fcc8a5&platform=pictique"; + expect(isDuplicateDelivery(url)).toBe(false); + + // Drawer takes the payload, user taps Decline. Mirrors + // handleAuthDrawerDecline exactly: no openUrl, so nothing durable. + clearDeepLinkFlow(); + declineDeepLink(); + + // The user presents the same link again. + reloadWebview(); + + expect(isDuplicateDelivery(url)).toBe(false); + }); + + it("still collapses the double delivery when a decline comes fast", () => { + // The half that must keep working: Android delivers a cold-start URL + // through both getCurrent() and onOpenUrl. A decline arriving before + // the duplicate must not let that second delivery re-open the drawer + // inside the SAME webview. + // + // Note this relies on the in-flight marker, which clearDeepLinkFlow + // releases when the drawer takes ownership — that release is + // deliberate, so a rebuilt webview can reopen an unanswered request. + const url = "w3ds://auth?session=21fcc8a5&platform=pictique"; + expect(isDuplicateDelivery(url)).toBe(false); + + markDeepLinkHandled(undefined, false); + + expect(isDuplicateDelivery(url)).toBe(true); + }); + + it("does not strand a URL when the app dies mid-request", () => { + // The user opens a link, the consent screen appears, and the app is + // killed before they confirm. The request never completed, so nothing + // promoted it to "handled" — and the in-flight marker must not survive + // to block the very same link on the next launch, or that login is + // permanently unreachable. + const url = "w3ds://auth?session=sess-1&platform=example"; + expect(isDuplicateDelivery(url)).toBe(false); + + reloadWebview(); + + expect(isDuplicateDelivery(url)).toBe(false); + }); + + it("lets a rebuilt webview reopen a request the user has not answered", () => { + // THE Activity-recreate case. Following a w3ds link from the browser + // restarts the Activity, so Tauri builds a fresh webview and the plugin + // replays the original intent via getCurrent(). The consent drawer had + // been SHOWN but not answered, so that replay is the only delivery the + // new webview will ever get and must be honoured — otherwise the user + // watches the request appear and then vanish into the camera page. + const url = "w3ds://auth?session=sess-1&platform=example"; + expect(isDuplicateDelivery(url)).toBe(false); + + // Drawer took ownership of the payload; the user has NOT decided yet. + clearDeepLinkFlow(); + + reloadWebview(); + + expect(isDuplicateDelivery(url)).toBe(false); + }); +}); + +describe("authentication is deliberately NOT durable", () => { + it("keeps the post-login confirmation across an app restart", () => { + // Approving calls openUrl, which leaves the app; coming back from the + // browser often restarts the Activity and destroys the webview. The + // drawer state is an in-memory Svelte store, so without this the user + // returned to a bare scanner page instead of "You're logged in!". + markDeepLinkCompleted({ + platform: "pictique", + hostname: "pictique.w3ds.metastate.foundation", + redirect: "https://pictique.w3ds.metastate.foundation/api/auth", + }); + + reloadWebview(); + + // Every field the drawer renders must survive, not just the name: the + // app icon is resolved from the hostname, so a name-only restore came + // back with a blank logo. + expect(takeCompletedDeepLink()).toEqual({ + platform: "pictique", + hostname: "pictique.w3ds.metastate.foundation", + redirect: "https://pictique.w3ds.metastate.foundation/api/auth", + }); + }); + + it("only hands the confirmation over once", () => { + markDeepLinkCompleted({ platform: "pictique" }); + + expect(takeCompletedDeepLink()).toEqual({ + platform: "pictique", + hostname: null, + redirect: null, + }); + expect(takeCompletedDeepLink()).toBeNull(); + }); + + it("reports no confirmation when none is pending", () => { + expect(takeCompletedDeepLink()).toBeNull(); + }); + + it("forgets the authenticated session when the webview reloads", () => { + // Security boundary. walletAuthenticated must not be persisted: the + // deep-link router and the splash both treat an authenticated session + // as already through the gate, so a durable marker would let a link + // arriving after an app kill skip authentication entirely. + markWalletAuthenticated(); + expect(isWalletAuthenticated()).toBe(true); + + reloadWebview(); + + expect(isWalletAuthenticated()).toBe(false); + }); +}); + +describe("logout", () => { + it("clears the authenticated marker so later deep links go to login", () => { + // Regression: logout is an SPA navigation, so sessionStorage survives + // it. A stale walletAuthenticated made the deep-link router treat the + // logged-out session as authenticated. + markWalletAuthenticated(); + markDeepLinkReady(AUTH_PAYLOAD); + + resetAuthSession(); + + expect(isWalletAuthenticated()).toBe(false); + expect(isDeepLinkFlowActive()).toBe(false); + expect(peekDeepLinkPayload()).toBeNull(); + }); + + it("clears a prompt bracket left open by an interrupted login", () => { + beginAuthPrompt(); + resetAuthSession(); + expect(isAuthPromptInFlight()).toBe(false); + }); +}); + +describe("post-auth handover", () => { + /** + * The handover in continueAfterSuccessfulAuth must be atomic: collect the + * payload, THEN release the prompt bracket, with no await in between. + * These cases model the two interleavings either side of that block and + * assert that exactly one actor is responsible for navigating in each. + */ + + it("collects a payload parked during the post-auth awaits", () => { + beginAuthPrompt(); + + // Auth succeeded; the routine is in its vault-read awaits. A URL + // lands. The bracket is still open, so the layout parks it rather than + // navigating. + markWalletAuthenticated(); + markDeepLinkPending(AUTH_PAYLOAD); + expect(isAuthPromptInFlight()).toBe(true); + + // Handover block: collect first... + promotePendingDeepLink(); + const hasPending = isDeepLinkFlowActive() && !!peekDeepLinkPayload(); + // ...then release. + endAuthPrompt(); + + expect(hasPending).toBe(true); + }); + + it("leaves a URL arriving after the handover to the layout", () => { + beginAuthPrompt(); + markWalletAuthenticated(); + + // Handover runs with nothing pending, so the routine heads for /main. + promotePendingDeepLink(); + const hasPending = isDeepLinkFlowActive() && !!peekDeepLinkPayload(); + endAuthPrompt(); + expect(hasPending).toBe(false); + + // The URL lands just after. The bracket is closed and the session is + // authenticated, so the layout routes it to the consent screen itself + // — nobody is waiting on a payload that never arrives. + markDeepLinkReady(AUTH_PAYLOAD); + expect(isAuthPromptInFlight()).toBe(false); + expect(isWalletAuthenticated()).toBe(true); + expect(peekDeepLinkPayload()).not.toBeNull(); + }); + + it("is safe for the caller to close an already-released bracket", () => { + // Callers close the bracket in a finally block; on the success path + // continueAfterSuccessfulAuth has already done it. + beginAuthPrompt(); + endAuthPrompt(); + expect(() => endAuthPrompt()).not.toThrow(); + expect(isAuthPromptInFlight()).toBe(false); + }); +}); + +describe("superseded splash/login continuation", () => { + // An async onMount is not cancelled when its component unmounts. The + // splash sleeps 1.2s and then awaits storage, so on a cold start it is + // still suspended while the user authenticates and the consent drawer + // opens. Both screens guard their continuations with a liveness check + // that asks this module whether the user is already through the gate. + // Bind to the real exported guard, not a local re-statement of it, so + // deleting the guard from the app breaks these tests. + // Screens snapshot the auth state when their routine starts and pass it + // back in, so the guard can tell a TRANSITION from a screen that simply + // mounted while already authenticated. + const stillOwnsTheScreen = (destroyed: boolean, authedAtStart = false) => + !shouldAbortStaleContinuation(destroyed, authedAtStart); + + it("tells a still-mounted splash it may proceed", () => { + expect(stillOwnsTheScreen(false)).toBe(true); + }); + + it("stops a splash that woke up after authentication completed", () => { + // This is the reported bug: the consent drawer is on screen, then the + // splash's parked continuation resumes and navigates away from it. + markWalletAuthenticated(); + + expect(stillOwnsTheScreen(false)).toBe(false); + }); + + it("stops a splash that woke up after being unmounted", () => { + expect(stillOwnsTheScreen(true)).toBe(false); + }); + + it("keeps the payload intact when a stale continuation is abandoned", () => { + // Bailing out must not disturb the flow the live screen is running. + markDeepLinkPending(AUTH_PAYLOAD); + markWalletAuthenticated(); + + expect(stillOwnsTheScreen(false)).toBe(false); + expect(isDeepLinkFlowActive()).toBe(true); + expect(peekDeepLinkPayload()).toBe(JSON.stringify(AUTH_PAYLOAD)); + }); + + it("lets a screen that mounted already-authenticated keep running", () => { + // Regression: /login is reached WITH an authenticated session during a + // deep-link flow (auth completes, then a guard bounces here). Treating + // that as a stale continuation made it return before prompting, so the + // biometric prompt never appeared and only the PIN pad was offered. + markWalletAuthenticated(); + + expect(stillOwnsTheScreen(false, true)).toBe(true); + }); + + it("still stops that screen once it is unmounted", () => { + markWalletAuthenticated(); + + expect(stillOwnsTheScreen(true, true)).toBe(false); + }); +}); + +describe("single biometric prompt site", () => { + // The biometric dialog used to be fired from BOTH the splash and /login. + // Both screens prompted on mount, so whichever won the race decided which + // backdrop the system dialog appeared over. The splash is now the only + // prompt site, which means the deep-link handler must stop navigating away + // from whoever owns that prompt. + + it("keeps a deep-link launch on the splash so it still gets biometrics", () => { + // The handler used to goto("/login") the moment a cold-start URL was + // parked. That unmounted the splash before it could prompt, so a + // deep-link launch was PIN-only by construction. + markDeepLinkPending(AUTH_PAYLOAD); + claimSplashAuthOwnership(); + + expect(shouldRedirectToLogin(false)).toBe(false); + }); + + it("does not navigate while a prompt is on screen", () => { + beginAuthPrompt(); + + expect(shouldRedirectToLogin(true, false)).toBe(false); + }); + + it("still routes to login when no screen owns the prompt", () => { + // Without this the payload would be parked with nobody to collect it. + expect(shouldRedirectToLogin(false, false)).toBe(true); + }); + + it("reads the live claims when none are supplied", () => { + expect(shouldRedirectToLogin()).toBe(true); + + claimSplashAuthOwnership(); + expect(shouldRedirectToLogin()).toBe(false); + releaseSplashAuthOwnership(); + expect(shouldRedirectToLogin()).toBe(true); + + beginAuthPrompt(); + expect(shouldRedirectToLogin()).toBe(false); + endAuthPrompt(); + expect(shouldRedirectToLogin()).toBe(true); + }); + + it("keeps the launch on the splash during its intro animation", () => { + // THE cold-start case, and the one the pathname check hid. The deep + // link is delivered from the root layout's onMount, which runs while + // the splash is still playing its ~1.2s intro — long before it reaches + // the biometric prompt. + // + // The splash therefore claims ownership at component INIT, not when it + // is finally ready to authenticate. Claiming late left a window of + // over a second in which the URL saw no owner, so the handler + // navigated to /login and unmounted the splash before it could prompt. + // Since /login is PIN-only, the user got the PIN pad instead of + // biometrics and the payload was left for a screen that never routes + // it. + claimSplashAuthOwnership(); + + // Delivery lands mid-intro: no prompt is on screen yet. + markDeepLinkPending(AUTH_PAYLOAD); + + expect(isAuthPromptInFlight()).toBe(false); + expect(shouldRedirectToLogin()).toBe(false); + }); + + it("routes a URL re-delivered after the splash finished its handover", () => { + // THE regression that made the consent screen vanish on fast + // authentication, and the reason ownership cannot be a pathname check. + // + // The splash authenticates, continueAfterSuccessfulAuth collects the + // payload, releases the prompt bracket and calls goto("/scan-qr"). + // SvelteKit navigation is async, so location.pathname is STILL "/" + // while that goto is in flight. A duplicate delivery landing in that + // window used to see path "/" and defer to an owner that had already + // finished, leaving the payload parked with nobody to collect it. + claimSplashAuthOwnership(); + beginAuthPrompt(); + + // Handover completes and the splash hands off. + markWalletAuthenticated(); + endAuthPrompt(); + releaseSplashAuthOwnership(); + + // The re-delivered URL must now be routed, not deferred, even though + // the pathname has not caught up yet. + expect(shouldRedirectToLogin()).toBe(true); + }); + + it("releases ownership when the user declines biometrics", () => { + // The splash falls through to /login on cancel. If the claim leaked, + // every later deep link would defer to a screen that is gone. + claimSplashAuthOwnership(); + releaseSplashAuthOwnership(); + + expect(shouldRedirectToLogin()).toBe(true); + }); + + it("forgets a leaked ownership claim on logout", () => { + claimSplashAuthOwnership(); + + resetAuthSession(); + + expect(shouldRedirectToLogin()).toBe(true); + }); +}); + +describe("acknowledged confirmation", () => { + // Approving a login calls openUrl, which restarts the Activity. If the + // user taps Ok inside the short window BEFORE that restart lands, the + // rebuilt webview reloads /scan-qr with the payload suppressed and the + // confirmation already consumed. It then saw "no deep link" and opened the + // camera — a scanner the user never asked for. + + it("suppresses the scanner after the user dismisses the confirmation", () => { + markDeepLinkCompleted({ platform: "pictique", hostname: "p.example" }); + + // The user taps Ok before the restart lands. + expect(takeCompletedDeepLink(true)).not.toBeNull(); + + // The rebuilt webview finds nothing to show and must NOT start the + // camera. + expect(takeCompletedDeepLink()).toBeNull(); + expect(wasDeepLinkJustAcknowledged()).toBe(true); + }); + + it("does not suppress the scanner merely for rendering the confirmation", () => { + markDeepLinkCompleted({ platform: "pictique", hostname: "p.example" }); + + // Restoring the card after a restart is not a dismissal: the user has + // not answered yet, so a later genuine scan must still work. + expect(takeCompletedDeepLink()).not.toBeNull(); + + expect(wasDeepLinkJustAcknowledged()).toBe(false); + }); + + it("records the dismissal even when the card was already restored", () => { + // The restore path consumes the record when it RENDERS, so by the time + // Ok is tapped there is nothing left to take. The acknowledgement must + // still be written or the restart reopens the camera. + markDeepLinkCompleted({ platform: "pictique", hostname: "p.example" }); + takeCompletedDeepLink(); + + expect(takeCompletedDeepLink(true)).toBeNull(); + + expect(wasDeepLinkJustAcknowledged()).toBe(true); + }); + + it("lets the user open the scanner deliberately right after", () => { + markDeepLinkCompleted({ platform: "pictique", hostname: "p.example" }); + takeCompletedDeepLink(true); + + // Tapping Scan is an in-app navigation, which proves intent. Without + // this the user would be bounced back to /main for 30 seconds. + clearDeepLinkAcknowledged(); + + expect(wasDeepLinkJustAcknowledged()).toBe(false); + }); + + it("expires so it can never suppress a later scan", () => { + vi.useFakeTimers(); + try { + markDeepLinkCompleted({ + platform: "pictique", + hostname: "p.example", + }); + takeCompletedDeepLink(true); + expect(wasDeepLinkJustAcknowledged()).toBe(true); + + vi.advanceTimersByTime(31_000); + + expect(wasDeepLinkJustAcknowledged()).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + + it("does not re-offer a finished login after a slow browser round-trip", () => { + // Reported: approve in the wallet, spend a while on the platform in + // Chrome, come back, tap Ok before the Activity restart lands — and + // the consent drawer re-opened on the login just completed. + // + // HANDLED_AT is stamped at APPROVAL, before the openUrl handoff, the + // time on the platform, and the restart on the way back. A leisurely + // round-trip outlives the 30s replay window, so the replayed intent + // was read as a genuine new request and the payload re-stored. + vi.useFakeTimers(); + try { + const url = "w3ds://auth?session=21fcc8a5&platform=pictique"; + expect(isDuplicateDelivery(url)).toBe(false); + + // User approves and is handed off to the browser. + markDeepLinkHandled(); + markDeepLinkCompleted({ platform: "pictique" }); + + // A slow round-trip: longer than the replay window. + vi.advanceTimersByTime(45_000); + + // Back in the app, the user taps Ok on the confirmation. + takeCompletedDeepLink(true); + + // The Activity restart lands now and replays the original intent. + reloadWebview(); + + expect(isDuplicateDelivery(url)).toBe(true); + } finally { + vi.useRealTimers(); + } + }); + + it("still honours a genuine retry long after the login was dismissed", () => { + // The counterweight: refreshing the window at Ok must not resurrect a + // permanent blacklist. The same offer URI is reused while its QR is on + // screen, so presenting it again later is a real request. + vi.useFakeTimers(); + try { + const url = "w3ds://auth?session=21fcc8a5&platform=pictique"; + isDuplicateDelivery(url); + markDeepLinkHandled(); + markDeepLinkCompleted({ platform: "pictique" }); + takeCompletedDeepLink(true); + + vi.advanceTimersByTime(31_000); + reloadWebview(); + + expect(isDuplicateDelivery(url)).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + + it("forgets the acknowledgement on logout", () => { + markDeepLinkCompleted({ platform: "pictique", hostname: "p.example" }); + takeCompletedDeepLink(true); + + resetAuthSession(); + + expect(wasDeepLinkJustAcknowledged()).toBe(false); + }); +}); diff --git a/infrastructure/eid-wallet/src/lib/utils/deepLinkFlow.ts b/infrastructure/eid-wallet/src/lib/utils/deepLinkFlow.ts new file mode 100644 index 000000000..1494bf52c --- /dev/null +++ b/infrastructure/eid-wallet/src/lib/utils/deepLinkFlow.ts @@ -0,0 +1,564 @@ +/** + * Deep-link flow state, shared by the root layout, the splash screen, /login + * and /scan-qr. + * + * WHY THIS MODULE EXISTS + * + * A `w3ds://` URL that cold-starts the app has to survive a journey across + * four independent pieces of code before the consent drawer can be shown: + * + * root +layout receives the URL (getCurrent / onOpenUrl) + * splash or /login authenticates the user (biometric or PIN) + * postLogin routes the authenticated user onward + * /scan-qr finally renders the consent drawer + * + * Those pieces run CONCURRENTLY on a cold start. The URL may be delivered + * before, during, or after authentication finishes, and on Android a + * cold-start URL frequently arrives through the async `onOpenUrl` callback + * *after* `getCurrent()` has already returned nothing. Every previous attempt + * to fix the "consent screen disappears" bug assumed a fixed ordering, which + * is why it only reproduced when biometric authentication completed quickly. + * + * The state here is deliberately order-independent. Whoever gets there first + * records a fact; nobody infers ordering from the absence of a key. + * + * KEYS + * + * pendingDeepLink payload waiting for the user to authenticate + * deepLinkData payload ready for /scan-qr to consume + * walletAuthenticated the user completed authentication this session. Lets a + * LATE-arriving URL route straight to the consent screen + * instead of bouncing off a stale "not on an + * authenticated route" pathname check. + * authInFlight an authenticate() call is currently awaiting the user. + * While set, the layout must NOT issue its own + * navigation: the post-auth routine owns routing, and two + * concurrent goto() calls are exactly what used to strand + * the user on /main with the payload unconsumed. + * + * WHY THERE IS NO SEPARATE "FLOW ACTIVE" MARKER + * + * There used to be a sticky `deepLinkFlowActive` key here, justified by a + * supposed instant during promotion where neither payload key was set. That + * instant does not exist: `promotePendingDeepLink` writes `deepLinkData` + * BEFORE removing `pendingDeepLink`, with no await in between, and every + * reader checks both keys. The payload is therefore visible under one key or + * the other at every observable moment. + * + * The sticky key was not merely redundant, it was harmful. It deliberately + * outlived the payload, so a login that had already been used still looked + * pending and was offered to the user again and again. The payload IS the + * request: when it is gone, the request is over. + */ + +const PENDING_KEY = "pendingDeepLink"; +const DATA_KEY = "deepLinkData"; +const AUTHED_KEY = "walletAuthenticated"; +const AUTH_IN_FLIGHT_KEY = "walletAuthInFlight"; +const LAST_URL_KEY = "deepLinkLastUrl"; +const HANDLED_URL_KEY = "deepLinkHandledUrl"; +const HANDLED_AT_KEY = "deepLinkHandledAt"; +const COMPLETED_KEY = "deepLinkCompleted"; +const ACKNOWLEDGED_KEY = "deepLinkAcknowledgedAt"; +const SPLASH_OWNS_AUTH_KEY = "splashOwnsAuthPrompt"; + +/** + * How long a just-handled URL keeps suppressing further deliveries. + * + * This must be long enough to cover the plugin replaying a stale intent after + * Android reloads the backgrounded webview (which happens within a second or + * two of returning from the browser), and short enough that presenting the + * SAME link again later is treated as the new request it is. + * + * That second half is not hypothetical: platforms mint one `session` per + * offer, not per launch, and the login QR is only refreshed every 60s. So the + * identical URL is genuinely re-delivered when a user retries a login that is + * still pending, and a permanent blacklist silently swallowed it — the + * approval screen simply never appeared again. + */ +const REPLAY_WINDOW_MS = 30_000; + +function store(): Storage | null { + try { + return typeof sessionStorage === "undefined" ? null : sessionStorage; + } catch { + // Private-mode / disabled storage: degrade to "no deep link in flight" + // rather than throwing inside a deep-link callback. + return null; + } +} + +/** + * Storage for facts that must survive the WEBVIEW being torn down and rebuilt, + * not merely the SPA navigations this flow performs. + * + * Completing a deep-link login calls `openUrl`, which sends the user out to the + * browser. Android is free to reload the wallet's webview while it is + * backgrounded, and that wipes sessionStorage — but NOT the Activity, which is + * `singleTask`, so the deep-link plugin still replays the original intent from + * `getCurrent()`. A dedupe marker kept in sessionStorage therefore cannot + * survive long enough to recognise the very replay it exists to suppress. + * + * ONLY the dedupe markers live here. Emphatically NOT `walletAuthenticated`: + * making that durable would let a deep link arriving after a full app kill + * skip authentication entirely, because the deep-link router and the splash + * both treat an authenticated session as "already through the gate". Being + * forgotten on relaunch is exactly the property that makes it safe. + */ +function durableStore(): Storage | null { + try { + return typeof localStorage === "undefined" ? null : localStorage; + } catch { + return null; + } +} + +/* ---------------------------------------------------------------- payloads */ + +/** A deep link arrived and the user still has to authenticate. */ +export function markDeepLinkPending(data: unknown): void { + const s = store(); + if (!s) return; + s.setItem(PENDING_KEY, JSON.stringify(data)); +} + +/** A deep link arrived and the user is already authenticated. */ +export function markDeepLinkReady(data: unknown): void { + const s = store(); + if (!s) return; + s.setItem(DATA_KEY, JSON.stringify(data)); + s.removeItem(PENDING_KEY); +} + +/** + * True while a deep-link request is still waiting to be dealt with. + * + * This is exactly "a payload is present", under either key. Once the consent + * screen has consumed the payload the request is finished, and this reports + * false — which is what stops a spent login being offered again. + */ +export function isDeepLinkFlowActive(): boolean { + return !!peekDeepLinkPayload(); +} + +/** + * Promote a pending payload to a ready one once authentication succeeds. + * Returns true when there was something to promote. + */ +export function promotePendingDeepLink(): boolean { + const s = store(); + if (!s) return false; + const pending = s.getItem(PENDING_KEY); + if (!pending) return false; + // Order matters: write the new key before dropping the old one, so a + // concurrent reader always sees the payload under one key or the other. + s.setItem(DATA_KEY, pending); + s.removeItem(PENDING_KEY); + return true; +} + +/** Read the payload without consuming it. */ +export function peekDeepLinkPayload(): string | null { + const s = store(); + if (!s) return null; + return s.getItem(DATA_KEY) ?? s.getItem(PENDING_KEY); +} + +/** The flow is finished: handled, declined, or failed. */ +export function clearDeepLinkFlow(): void { + const s = store(); + if (!s) return; + s.removeItem(PENDING_KEY); + s.removeItem(DATA_KEY); + // NOTE: this does NOT mark the URL as handled. It runs when the consent + // drawer takes ownership of the payload, which is the START of the user's + // decision, not the end of it. Marking it handled here is what made an + // Activity recreate fatal: the drawer had been shown, so the replay was + // suppressed, and the rebuilt webview had nothing to display. + // Completion is recorded by markDeepLinkHandled. + s.removeItem(LAST_URL_KEY); +} + +/** + * The user finished with this request: approved, declined, or it errored out. + * + * Only now is a further delivery of the same URL a stale replay worth + * dropping. + * + * `durable` distinguishes the two endings, and conflating them is what made a + * declined login impossible to retry: + * + * true the decision handed control to the BROWSER (approve calls openUrl). + * Returning from it restarts the Activity, so the plugin replays the + * original intent into a brand-new webview. Only a durable marker + * outlives that, so it has to be written to localStorage. + * + * false the decision kept the user inside the app (decline, or an error). + * No Activity restart is coming, so the only delivery still to + * suppress is Android's getCurrent()/onOpenUrl double-delivery within + * THIS webview. A durable marker here is actively harmful: platforms + * reuse one `session` per offer while its QR is on screen, so + * presenting the same link again is a legitimate retry — and a + * durable marker silently dropped it, leaving the user on /main with + * no consent screen at all. + */ +export function markDeepLinkHandled(urlString?: string, durable = true): void { + const d = durableStore(); + if (!d) return; + const url = urlString ?? d.getItem(LAST_URL_KEY); + if (!url) return; + + if (durable) { + d.setItem(HANDLED_URL_KEY, url); + d.setItem(HANDLED_AT_KEY, String(Date.now())); + } + + // Always clear the pointer to the request just finished. The session-scoped + // in-flight marker stays put, so this webview still collapses Android's + // double delivery of the very same URL. + d.removeItem(LAST_URL_KEY); +} + +/** What the confirmation drawer needs to render itself after a restart. */ +export interface CompletedDeepLink { + platform: string | null; + hostname: string | null; + redirect: string | null; +} + +/** + * Record that a deep-link login was approved and handed to the platform, so the + * confirmation can be shown even if the webview is rebuilt before it renders. + * + * Approving calls `openUrl`, which leaves the app; returning from the browser + * frequently restarts the Activity. The drawer state lives in an in-memory + * Svelte store, so it does not survive that, and the user came back to a bare + * scanner page instead of "You're logged in!". Durable because the very event + * we are surviving destroys the webview. + * + * Store every field the drawer renders, not just the platform name: the app + * icon is resolved from the HOSTNAME, so persisting the name alone brought the + * drawer back with a blank logo. + */ +export function markDeepLinkCompleted( + details: Partial, +): void { + durableStore()?.setItem( + COMPLETED_KEY, + JSON.stringify({ + platform: details.platform ?? null, + hostname: details.hostname ?? null, + redirect: details.redirect ?? null, + }), + ); +} + +/** + * Consume the pending confirmation, if one is waiting. Single-use. + * + * `acknowledged` distinguishes the two callers, and the distinction matters + * because the Activity restart can still be pending when the user acts: + * + * false (default) the confirmation is being RENDERED. It must not be shown + * twice, but the user has not dismissed it yet. + * true the user tapped Ok. The request is finished for good, so + * a webview rebuilt after this must not reopen the scanner + * as if the user had asked to scan something. + */ +export function takeCompletedDeepLink( + acknowledged = false, +): CompletedDeepLink | null { + const d = durableStore(); + if (!d) return null; + // Record the acknowledgement FIRST. On the restore path the confirmation + // was already consumed when it was rendered, so by the time the user taps + // Ok there is no record left and the early return below would skip this. + if (acknowledged) { + d.setItem(ACKNOWLEDGED_KEY, String(Date.now())); + // Restart the replay-suppression window from the dismissal, not from + // the approval. + // + // HANDLED_AT is stamped when the user approves, which is BEFORE the + // openUrl handoff, the time spent on the platform in the browser, and + // the Activity restart on the way back. By the time the replayed + // intent finally arrives, that window may already have expired, so the + // finished login was treated as a genuine new request and the consent + // drawer re-opened on a login the user had just completed. + // + // The URL is unchanged, so refreshing the timestamp is enough; a + // genuinely new request carries a different `session`. + const handledUrl = d.getItem(HANDLED_URL_KEY); + if (handledUrl) d.setItem(HANDLED_AT_KEY, String(Date.now())); + } + const value = d.getItem(COMPLETED_KEY); + if (value === null) return null; + d.removeItem(COMPLETED_KEY); + try { + const parsed = JSON.parse(value) as Partial; + return { + platform: parsed.platform ?? null, + hostname: parsed.hostname ?? null, + redirect: parsed.redirect ?? null, + }; + } catch { + return { platform: null, hostname: null, redirect: null }; + } +} + +/* ------------------------------------------------------------ dedupe guard */ + +/** + * Did the user just dismiss a deep-link confirmation? + * + * /scan-qr is two different screens wearing one route. Reached from the Scan + * button it is a camera; reached by a deep link it is a consent/confirmation + * screen that happens to fall through to the camera when it finds no payload. + * That fall-through is correct for a deliberate scan and wrong for the tail of + * a finished login. + * + * Tapping Ok can be followed by the Activity restart that the login's own + * `openUrl` set in motion. The rebuilt webview reloads /scan-qr with the + * payload suppressed and the confirmation consumed, so it sees "no deep link" + * and opens the camera — a scanner the user never asked for. + * + * Bounded by the same window as the replay guard: it suppresses only the + * restart caused by the login just acknowledged, never a later genuine scan. + */ +export function wasDeepLinkJustAcknowledged(): boolean { + const d = durableStore(); + if (!d) return false; + const at = Number(d.getItem(ACKNOWLEDGED_KEY) ?? 0); + if (!at) return false; + if (Date.now() - at < REPLAY_WINDOW_MS) return true; + d.removeItem(ACKNOWLEDGED_KEY); + return false; +} + +/** + * Forget the acknowledgement, because the user has deliberately asked for the + * scanner. + * + * The marker must only ever suppress a webview REBUILD, never a navigation the + * user performed. A SPA navigation proves intent (the Scan button was tapped); + * an Activity restart produces a fresh page load and fires no navigation hook + * at all. Clearing here is what keeps "tap Ok, then immediately tap Scan" from + * bouncing the user straight back to /main. + */ +export function clearDeepLinkAcknowledged(): void { + durableStore()?.removeItem(ACKNOWLEDGED_KEY); +} + +/** + * True when this exact URL is already being handled. + * + * Android delivers a cold-start URL through BOTH `getCurrent()` and the + * `onOpenUrl` callback. Handling it twice fires two navigations at the consent + * screen and the second can unmount the drawer the first just opened. + * + * A URL is suppressed in two distinct situations, and conflating them breaks + * one flow or the other: + * + * in flight this URL is the request being processed right now. The + * second delivery of it is Android's duplicate and is + * dropped for as long as the request is open. + * recently handled the request just finished. The activity is `singleTask` + * and the plugin never clears `currentUrl`, so `getCurrent()` + * replays the original intent on the next webview load; + * acting on that restarts a finished login. + * + * The crucial correction: "recently" is bounded. An earlier version remembered + * handled URLs forever, on the assumption that every request carries a unique + * session id. That assumption is false — a session belongs to an offer, and the + * same offer URI is reused while its QR is on screen — so the permanent marker + * blacklisted legitimate retries and the approval screen stopped appearing at + * all. Suppression now expires after REPLAY_WINDOW_MS. + * + * Storage-backed, and durable, because the replay is delivered by an Activity + * that outlives the webview. + */ +export function isDuplicateDelivery(urlString: string): boolean { + const s = durableStore(); + if (!s) return false; + + if (s.getItem(HANDLED_URL_KEY) === urlString) { + const handledAt = Number(s.getItem(HANDLED_AT_KEY) ?? 0); + if (Date.now() - handledAt < REPLAY_WINDOW_MS) return true; + // Expired: this is a genuine retry of the same link. Forget the old + // verdict so the request is processed normally from here on. + s.removeItem(HANDLED_URL_KEY); + s.removeItem(HANDLED_AT_KEY); + } + + // "In flight" is scoped to THIS webview and nothing else. It exists solely + // to collapse the getCurrent()/onOpenUrl double delivery that happens + // within one run. + // + // It must NOT be durable. Following a w3ds link from the browser restarts + // the Activity, so Tauri builds a fresh webview and the plugin replays the + // original intent through getCurrent(). For that new webview the replay is + // not a duplicate — it is the ONLY delivery it will ever receive. An empty + // sessionStorage is exactly the signal that this is a new run, so the + // payload is stored again and the consent drawer can reopen. + const inFlight = store(); + if (!inFlight) return false; + if (inFlight.getItem(LAST_URL_KEY) === urlString) return true; + inFlight.setItem(LAST_URL_KEY, urlString); + // Mirrored durably only so markDeepLinkHandled knows which URL completed. + s.setItem(LAST_URL_KEY, urlString); + return false; +} + +/* ------------------------------------------------------------ auth signals */ + +/** Record that the user finished authenticating in this session. */ +export function markWalletAuthenticated(): void { + store()?.setItem(AUTHED_KEY, "true"); +} + +/** Has the user authenticated at any point in this session? */ +export function isWalletAuthenticated(): boolean { + return store()?.getItem(AUTHED_KEY) === "true"; +} + +/** + * Bracket an authenticate() / PIN-verify call. While a prompt is in flight the + * deep-link handler defers all navigation to the post-auth routine, so the two + * cannot race each other to a different destination. + */ +export function beginAuthPrompt(): void { + store()?.setItem(AUTH_IN_FLIGHT_KEY, "true"); +} + +export function endAuthPrompt(): void { + store()?.removeItem(AUTH_IN_FLIGHT_KEY); +} + +export function isAuthPromptInFlight(): boolean { + return store()?.getItem(AUTH_IN_FLIGHT_KEY) === "true"; +} + +/** + * Claim/release the pre-app auth prompt for the splash screen. + * + * Distinct from beginAuthPrompt, which brackets the moment the user's finger + * is actually on the sensor. This is the WIDER window: from the splash + * deciding it will authenticate, until it has handed the user onward. During + * that window the splash is the one screen responsible for routing a parked + * payload, so the deep-link handler must not navigate on its own. + * + * It exists because "the splash owns the prompt" cannot be inferred from + * `window.location.pathname === "/"`. SvelteKit navigation is asynchronous, so + * the pathname is still "/" for a while after the splash has called goto(). + * Treating that as "a prompt is coming" made the handler defer to an owner + * that had already finished, and the parked payload was never collected — the + * consent screen simply never appeared. + */ +export function claimSplashAuthOwnership(): void { + store()?.setItem(SPLASH_OWNS_AUTH_KEY, "true"); +} + +export function releaseSplashAuthOwnership(): void { + store()?.removeItem(SPLASH_OWNS_AUTH_KEY); +} + +export function splashOwnsAuthPrompt(): boolean { + return store()?.getItem(SPLASH_OWNS_AUTH_KEY) === "true"; +} + +/** + * Should the deep-link handler navigate an unauthenticated user to /login? + * + * Biometric authentication is prompted from exactly one place: the splash. + * Having two prompt sites made the dialog's backdrop non-deterministic — the + * splash and /login each ran their own authenticate() on mount, so whichever + * won the race decided whether the system dialog appeared over the purple + * splash or over a half-painted PIN pad. + * + * With a single prompt site, the handler must not steer away from the screen + * that owns it: + * + * - A prompt is already up: its post-auth routine collects the parked payload + * and routes. A goto() here would race that navigation. + * - The splash has CLAIMED the prompt: it is about to authenticate, or is + * mid-handover. Unmounting it now would discard the biometric prompt and + * dump the user on the PIN pad. It routes onward by itself in every exit + * path, so waiting costs nothing. + * + * Both conditions are explicit claims, never inferred from the pathname. The + * regression that made the consent screen vanish on fast authentication came + * from inferring ownership from `pathname === "/"`: after the splash's + * handover released the prompt bracket, a re-delivered URL still saw "/" for + * as long as the goto() took to land, so the handler deferred to an owner that + * no longer existed and the payload was left parked forever. + */ +export function shouldRedirectToLogin( + promptInFlight = isAuthPromptInFlight(), + splashOwns = splashOwnsAuthPrompt(), +): boolean { + if (promptInFlight) return false; + if (splashOwns) return false; + return true; +} + +/** + * Should a pre-app screen abandon an async routine it started earlier? + * + * Unmounting a Svelte component does NOT cancel an `onMount` that is parked on + * an `await`; the continuation resumes later and will happily call `goto()` + * from a screen the user left long ago. The splash sleeps for its intro + * animation and then awaits storage and the deep-link handshake, so on a cold + * start it is still suspended while the user authenticates and /scan-qr opens + * the consent drawer. Waking up at that point and running its tail is what + * tears the drawer back down. + * + * The question is "was this routine SUPERSEDED while it waited?", which is not + * the same as "is the session authenticated?". Testing the latter broke + * /login: arriving there after having authenticated once in the same session + * (which is exactly what a deep-link flow does) made the guard classify a + * freshly mounted screen as stale, so it returned before ever prompting and + * the user was left with only the PIN pad, unable to use biometrics. + * + * So callers snapshot the authentication state when the routine STARTS and + * pass it back in. Only a transition — not authenticated then, authenticated + * now — means another screen took ownership mid-wait. A screen that mounts + * already-authenticated sees no transition and proceeds normally. + * + * Bailing out is always safe: it only skips navigation, and never touches the + * deep-link payload the live screen still has to consume. + */ +export function shouldAbortStaleContinuation( + destroyed: boolean, + authenticatedAtStart = false, +): boolean { + if (destroyed) return true; + // Authentication completed elsewhere while this routine was suspended. + return !authenticatedAtStart && isWalletAuthenticated(); +} + +/** + * Wipe every trace of this login session. Call on logout. + * + * `walletAuthenticated` in particular MUST be cleared here. Logout resets the + * global state and does an SPA navigation to "/", which leaves sessionStorage + * intact — so without this the session would keep claiming the user is + * authenticated, and a deep link arriving afterwards would route itself to the + * consent screen instead of to /login. The (app) vault guard does catch that + * and bounce the user back, but relying on a second guard for a decision we + * can state correctly here is not a safety property worth betting on. + */ +export function resetAuthSession(): void { + const s = store(); + clearDeepLinkFlow(); + s?.removeItem(AUTH_IN_FLIGHT_KEY); + s?.removeItem(SPLASH_OWNS_AUTH_KEY); + s?.removeItem(AUTHED_KEY); + // The in-flight marker is mirrored in both stores; clear both or a link + // followed before logging out stays blocked afterwards. + s?.removeItem(LAST_URL_KEY); + const d = durableStore(); + d?.removeItem(HANDLED_URL_KEY); + d?.removeItem(HANDLED_AT_KEY); + d?.removeItem(LAST_URL_KEY); + d?.removeItem(COMPLETED_KEY); + d?.removeItem(ACKNOWLEDGED_KEY); +} diff --git a/infrastructure/eid-wallet/src/lib/utils/postLogin.ts b/infrastructure/eid-wallet/src/lib/utils/postLogin.ts index d90085955..6e0c1d657 100644 --- a/infrastructure/eid-wallet/src/lib/utils/postLogin.ts +++ b/infrastructure/eid-wallet/src/lib/utils/postLogin.ts @@ -1,5 +1,12 @@ import { goto } from "$app/navigation"; import type { GlobalState } from "$lib/global"; +import { + endAuthPrompt, + isDeepLinkFlowActive, + markWalletAuthenticated, + peekDeepLinkPayload, + promotePendingDeepLink, +} from "$lib/utils/deepLinkFlow"; /** * Shared post-authentication routine: fires the background eVault chores @@ -10,10 +17,23 @@ import type { GlobalState } from "$lib/global"; * 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. + * + * Callers must still have the auth prompt bracket OPEN when they call this + * (see beginAuthPrompt). This function closes it itself, at the one moment + * where closing it is safe. Ending the bracket in the caller first would + * reopen the race being fixed: the awaits below would then run unbracketed, so + * a deep link landing during them would issue its own navigation while this + * routine was on its way to a different destination. */ export async function continueAfterSuccessfulAuth( gs: GlobalState, ): Promise { + // Record the session as authenticated BEFORE any await. A deep link that + // lands while the chores below are in flight must be able to see that the + // user is already through the gate, so it routes itself straight to the + // consent screen instead of parking a payload nobody will collect. + markWalletAuthenticated(); + // 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 +72,41 @@ export async function continueAfterSuccessfulAuth( console.error("Error reading vault during login:", error); } - const pendingDeepLink = sessionStorage.getItem("pendingDeepLink"); - if (pendingDeepLink) { + // ---- Handover. Everything from here to the goto() runs synchronously. ---- + // + // No `await` may be introduced in this block. JavaScript is single + // threaded, so with no suspension point a deep-link callback cannot + // interleave between releasing the prompt bracket and reading the payload. + // That is what makes the handover atomic: every URL is either parked + // before this block (and collected here) or delivered after it (and + // routed by the layout itself, which by now sees an authenticated + // session). There is no third case, and so no window in which a payload is + // stored but nobody is left to act on it. + + // Collect anything parked while the user was authenticating. Both steps + // matter: the payload may have been stored before the prompt (promote + // finds it) or delivered during the awaits above and written straight to + // deepLinkData (only the flag sees it). + promotePendingDeepLink(); + const hasPendingDeepLink = + isDeepLinkFlowActive() && !!peekDeepLinkPayload(); + + // Release the bracket. From this instant the layout resumes navigating for + // itself, which is correct: the session is now marked authenticated, so a + // late URL routes straight to the consent screen. + endAuthPrompt(); + + if (hasPendingDeepLink) { try { - sessionStorage.setItem("deepLinkData", pendingDeepLink); - sessionStorage.removeItem("pendingDeepLink"); - await goto("/scan-qr"); + await goto("/scan-qr", { replaceState: true }); return; } catch (error) { - console.error("Error processing pending deep link:", error); - sessionStorage.removeItem("pendingDeepLink"); - sessionStorage.removeItem("deepLinkData"); + // Leave the payload in place — /scan-qr clears it once handled, and + // a failed navigation here should not silently discard the user's + // pending login request. + console.error("Error navigating to pending deep link:", error); } } - await goto("/main"); + await goto("/main", { replaceState: true }); } diff --git a/infrastructure/eid-wallet/src/routes/(app)/+layout.svelte b/infrastructure/eid-wallet/src/routes/(app)/+layout.svelte index 361bc0533..14195cffc 100644 --- a/infrastructure/eid-wallet/src/routes/(app)/+layout.svelte +++ b/infrastructure/eid-wallet/src/routes/(app)/+layout.svelte @@ -2,6 +2,7 @@ import { goto } from "$app/navigation"; import { page } from "$app/state"; import type { GlobalState } from "$lib/global"; +import { isWalletAuthenticated } from "$lib/utils/deepLinkFlow"; import type { PluginListener } from "@tauri-apps/api/core"; import type { Snippet } from "svelte"; import { getContext, onDestroy, onMount } from "svelte"; @@ -13,6 +14,26 @@ let currentRoute = $derived(page.url.pathname.split("/").pop() || "home"); let globalState: GlobalState | undefined = $state(undefined); let notificationListener: PluginListener | undefined; +// This guard polls for global state and then retries the vault read, so it can +// still be parked on an await long after the user has moved on. Unmounting does +// NOT cancel it, and its failure path calls goto("/login") — which is how a +// completed login ended up back on the PIN screen. Bail out if this layout is +// gone, and never bounce a session that has already authenticated. +let destroyed = false; +onDestroy(() => { + destroyed = true; +}); + +/** Send an unauthenticated visitor to /login, unless we are stale. */ +async function bounceToLogin(reason: string) { + if (destroyed || isWalletAuthenticated()) { + console.log("[APP GUARD] stale, not redirecting |", reason); + return; + } + console.log("[APP GUARD]", reason); + await goto("/login"); +} + onMount(async () => { // Get global state — poll briefly since root layout's init is async and // can land after this guard mounts on a hard reload. @@ -21,6 +42,7 @@ onMount(async () => { let retries = 0; while (!globalState && retries < 50) { await new Promise((r) => setTimeout(r, 100)); + if (destroyed) return; globalState = getGlobalState(); retries++; } @@ -28,8 +50,7 @@ onMount(async () => { // Authentication guard for all app routes try { if (!globalState) { - console.log("No global state, redirecting to login"); - await goto("/login"); + await bounceToLogin("no global state"); return; } @@ -39,17 +60,17 @@ onMount(async () => { let vaultRetries = 0; while (!vault && vaultRetries < 10) { await new Promise((r) => setTimeout(r, 100)); + if (destroyed) return; vault = await globalState.vaultController.vault; vaultRetries++; } if (!vault) { - console.log( - "[APP GUARD] vault missing after retry, redirecting to login | path:", - page.url.pathname, + await bounceToLogin( + `vault missing after retry | path: ${page.url.pathname}`, ); - await goto("/login"); return; } + if (destroyed) return; console.log("User authenticated, allowing access to app routes"); @@ -87,8 +108,7 @@ onMount(async () => { console.error("Failed to check notifications:", error); } } catch (error) { - console.log("Authentication check failed, redirecting to login"); - await goto("/login"); + await bounceToLogin(`authentication check failed: ${error}`); return; } }); diff --git a/infrastructure/eid-wallet/src/routes/(app)/scan-qr/+page.svelte b/infrastructure/eid-wallet/src/routes/(app)/scan-qr/+page.svelte index 0467e5d4d..02f494e01 100644 --- a/infrastructure/eid-wallet/src/routes/(app)/scan-qr/+page.svelte +++ b/infrastructure/eid-wallet/src/routes/(app)/scan-qr/+page.svelte @@ -2,6 +2,10 @@ import { goto } from "$app/navigation"; import AppNav from "$lib/fragments/AppNav/AppNav.svelte"; import type { GlobalState } from "$lib/global"; +import { + markDeepLinkHandled, + takeCompletedDeepLink, +} from "$lib/utils/deepLinkFlow"; import { getContext, onDestroy, onMount } from "svelte"; import type { SVGAttributes } from "svelte/elements"; import { get } from "svelte/store"; @@ -13,8 +17,28 @@ import SigningDrawer from "./components/SigningDrawer.svelte"; import SocialBindingDrawer from "./components/SocialBindingDrawer.svelte"; import { createScanLogic } from "./scanLogic"; -const globalState = getContext<() => GlobalState>("globalState")(); -const { stores, actions } = createScanLogic({ globalState, goto }); +// Resolve global state LAZILY. /scan-qr is not only the scanner: it renders the +// deep-link consent drawer, so the deep-link flow navigates here on its own. On +// a fresh webview load (Android reloads it while the app is backgrounded during +// the browser handoff) this page is restored as the current route and mounts +// BEFORE the root layout's onMount has created global state. Calling the +// context getter once here captured `undefined` forever, and the first +// `globalState.vaultController` then threw — which scanLogic's catch reported +// as "authentication check failed" and redirected to /login. +const getGlobalState = getContext<() => GlobalState | undefined>("globalState"); +// Tapping a w3ds link from the browser restarts the Activity (singleTask with +// a VIEW filter, and no onNewIntent override), so Tauri builds a BRAND NEW +// webview. This page is then restored as the current route and mounts before +// the root layout's async onMount has even received the URL. Await the +// layout's discovery signal before concluding there is no payload. +const initialDeepLinkReady = getContext | undefined>( + "initialDeepLinkReady", +); +const { stores, actions } = createScanLogic({ + getGlobalState, + initialDeepLinkReady, + goto, +}); const { platform, @@ -108,20 +132,36 @@ $effect(() => { }); async function handleAuthDrawerDecline() { - // Cancel button always navigates to main + // The user decided. Record completion so Android's second delivery of the + // same cold-start URL does not immediately re-open the drawer they just + // dismissed. + // + // Session-scoped, NOT durable: declining keeps the user inside the app, so + // no Activity restart is coming and there is no cross-webview replay to + // suppress. A durable marker here blocked the retry instead — platforms + // reuse one `session` per offer, so tapping the same login link again is a + // legitimate new request, and it was being silently dropped, leaving the + // user on /main with no consent screen. + markDeepLinkHandled(undefined, false); setCodeScannedDrawerOpen(false); - await goto("/main"); + await goto("/main", { replaceState: true }); } function handleAuthDrawerOpenChange(value: boolean) { setCodeScannedDrawerOpen(value); } -function handleLoggedInDrawerConfirm() { +async function handleLoggedInDrawerConfirm() { setLoggedInDrawerOpen(false); - goto("/main").then(() => { - startScan(); - }); + // Acknowledged: drop the stored confirmation so a later app restart cannot + // resurrect it, and record the dismissal. The Activity restart caused by + // this login's own openUrl can still be pending, and the rebuilt webview + // would otherwise find no payload and open the camera. + takeCompletedDeepLink(true); + // /scan-qr is a transient deep-link destination. Replace it so Android + // back cannot reopen the camera after login, and never start the camera + // after this page has navigated away. + await goto("/main", { replaceState: true }); } function handleLoggedInDrawerOpenChange(value: boolean) { @@ -131,7 +171,7 @@ function handleLoggedInDrawerOpenChange(value: boolean) { async function handleSigningDrawerDecline() { // Cancel button always navigates to main setSigningDrawerOpen(false); - await goto("/main"); + await goto("/main", { replaceState: true }); } function handleSigningDrawerOpenChange(value: boolean) { 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..e281bc22e 100644 --- a/infrastructure/eid-wallet/src/routes/(app)/scan-qr/scanLogic.ts +++ b/infrastructure/eid-wallet/src/routes/(app)/scan-qr/scanLogic.ts @@ -21,6 +21,14 @@ import { getCanonicalBindingDocString, resolveVaultUri, } from "$lib/utils"; +import { + clearDeepLinkFlow, + markDeepLinkCompleted, + markDeepLinkHandled, + peekDeepLinkPayload, + takeCompletedDeepLink, + wasDeepLinkJustAcknowledged, +} from "$lib/utils/deepLinkFlow"; export interface SigningData { type?: string; @@ -56,7 +64,16 @@ export interface RevealedVoteData { } interface CreateScanLogicParams { - globalState: GlobalState; + /** + * Lazy accessor rather than a value: this page can mount before the root + * layout has finished creating global state (see +page.svelte). + */ + getGlobalState: () => GlobalState | undefined; + /** + * Resolves once the root layout has finished asking the deep-link plugin + * whether the app was opened by a URL. Undefined outside that layout. + */ + initialDeepLinkReady?: Promise; goto: (path: string) => Promise; } @@ -143,9 +160,40 @@ interface ScanLogic { let scanInFlight = false; export function createScanLogic({ - globalState, + getGlobalState, + initialDeepLinkReady, goto, }: CreateScanLogicParams): ScanLogic { + /** + * Wait for the root layout to publish global state. + * + * Returns undefined only if it never arrives, which is a genuine failure. + * Everything below reads through this instead of capturing the value once, + * so a mount that beats the layout recovers instead of throwing. + */ + async function requireGlobalState(): Promise { + let gs = getGlobalState(); + let retries = 0; + while (!gs && retries < 50) { + await new Promise((r) => setTimeout(r, 100)); + gs = getGlobalState(); + retries++; + } + return gs; + } + + /** + * Same, but for the user-initiated handlers below, which cannot proceed at + * all without global state. They all run after the user has interacted with + * a drawer, so it is present in practice; this throws a NAMED error rather + * than a TypeError on undefined if that ever stops being true. + */ + async function mustGlobalState(): Promise { + const gs = await requireGlobalState(); + if (!gs) throw new Error("Global state unavailable"); + return gs; + } + const platform = writable(null); const hostname = writable(null); const session = writable(null); @@ -338,6 +386,7 @@ export function createScanLogic({ } async function handleAuth() { + const globalState = await mustGlobalState(); const vault = await globalState.vaultController.vault; if (!vault || !get(redirect)) return; @@ -370,8 +419,17 @@ export function createScanLogic({ const fromScan = get(isFromScan); + // `redirect` is the platform's API login endpoint (for example + // https://host/api/auth), which only accepts POST. How the + // credentials reach it differs by flow: + // + // scan the browser that showed the QR is on another device, + // so the wallet POSTs itself and the platform picks the + // session up over SSE. + // deep link the platform opened us on this device and its + // /deeplink-login PAGE does the POST, then signs the + // user in. We just hand it the parameters. if (fromScan) { - // For scan: Make POST request with JSON payload const payload = { ename: vault.ename, session: get(session) as string, @@ -380,7 +438,6 @@ export function createScanLogic({ }; console.log(`📤 Making POST request to: ${redirectUrl}`); - console.log("📦 Payload:", payload); const response = await fetch(redirectUrl, { method: "POST", @@ -398,14 +455,21 @@ export function createScanLogic({ console.log("✅ POST request successful"); - // For scan: Close drawer and show success, skip deeplink redirect logic + // The user's decision is complete; further deliveries of this + // URL are stale replays. + // + // Session-scoped: this branch POSTs from inside the app and + // never calls openUrl, so no Activity restart follows and + // there is no cross-webview replay to outlive. Marking it + // durably would block a legitimate retry of the same offer. + markDeepLinkHandled(undefined, false); + codeScannedDrawerOpen.set(false); loggedInDrawerOpen.set(true); startScan(); return; } - // For deeplink: Open URL with encoded URI - // Strip path from redirectUri and append /deeplink-login + const loginUrl = new URL("/deeplink-login", redirectUrl); loginUrl.searchParams.set("ename", vault.ename); loginUrl.searchParams.set("session", get(session) as string); @@ -414,99 +478,28 @@ export function createScanLogic({ console.log(`🔗 Opening login URL: ${loginUrl.toString()}`); - // Ensure we are on home before triggering external deeplink (non-blocking) - goto("/main").catch((err) => { - console.error( - "Failed to navigate to home before deep link:", - err, - ); - }); - - // Open URL in browser using tauri opener - await openUrl(loginUrl.toString()); - - // Close the auth drawer first + // Hand off to the platform in the browser. Nothing may navigate + // the webview after this: the old code set window.location.href = + // redirect, pointing it at the POST-only API endpoint, which is + // what rendered "Cannot GET /api/auth" over a successful login. codeScannedDrawerOpen.set(false); - let deepLinkData = sessionStorage.getItem("deepLinkData"); - if (!deepLinkData) { - deepLinkData = sessionStorage.getItem("pendingDeepLink"); - } - - if (deepLinkData) { - try { - const data = JSON.parse(deepLinkData) as DeepLinkData; - console.log( - "Deep link data found after auth completion:", - data, - ); - - if (data.type === "auth") { - if ( - !data.redirect || - typeof data.redirect !== "string" - ) { - console.error( - "Invalid redirect URL:", - data.redirect, - ); - // Ensure auth drawer is closed before opening logged in drawer - codeScannedDrawerOpen.set(false); - loggedInDrawerOpen.set(true); - return; - } - - try { - new URL(data.redirect); - } catch (urlError) { - console.error("Invalid URL format:", urlError); - // Ensure auth drawer is closed before opening logged in drawer - codeScannedDrawerOpen.set(false); - loggedInDrawerOpen.set(true); - return; - } + // Record BOTH facts before leaving the app. openUrl hands control + // to the browser, and returning from it often restarts the + // Activity, which destroys this webview along with every in-memory + // store. Anything set after this point may never be rendered. + markDeepLinkHandled(); + markDeepLinkCompleted({ + platform: get(platform), + hostname: get(hostname), + redirect: redirectUrl, + }); - try { - window.location.href = data.redirect; - } catch (error1) { - console.log( - "Method 1 failed, trying method 2:", - error1, - ); - try { - window.location.assign(data.redirect); - } catch (error2) { - console.log( - "Method 2 failed, trying method 3:", - error2, - ); - try { - window.location.replace(data.redirect); - } catch (error3) { - console.log( - "Method 3 failed, using fallback:", - error3, - ); - throw new Error( - "All redirect methods failed", - ); - } - } - } - return; - } - } catch (error) { - console.error( - "Error parsing deep link data for redirect:", - error, - ); - } - } else { - console.log("No deep link data found after auth completion"); - } + await openUrl(loginUrl.toString()); - // Ensure auth drawer is closed before opening logged in drawer - codeScannedDrawerOpen.set(false); + // Show the same "You're logged in!" confirmation the scan flow + // gets, rather than dumping the user straight on the home screen. + // Its Ok button is what returns them to /main. loggedInDrawerOpen.set(true); } catch (error) { console.error("Error completing authentication:", error); @@ -717,6 +710,7 @@ export function createScanLogic({ } async function handleSocialBinding() { + const globalState = await mustGlobalState(); const requesterEname = get(socialBindingRequesterEname); if (!requesterEname) return; @@ -888,6 +882,7 @@ export function createScanLogic({ } async function handleSignVote() { + const globalState = await mustGlobalState(); const currentSigningData = get(signingData); const currentSigningSessionId = get(signingSessionId); if (!currentSigningData || !currentSigningSessionId) return; @@ -946,18 +941,13 @@ export function createScanLogic({ } showSigningSuccess.set(true); - const deepLinkData = sessionStorage.getItem("deepLinkData"); - if (deepLinkData) { - try { - const data = JSON.parse(deepLinkData) as DeepLinkData; - if (data.type === "sign") { - console.log("Signing completed via deep link"); - startScan(); - return; - } - } catch (error) { - console.error("Error parsing deep link data:", error); - } + // Came from a deep link rather than the camera: resume scanning + // behind the success sheet. Uses the in-memory flag because the + // deep-link storage keys are cleared once the drawer opens. + if (!get(isFromScan)) { + console.log("Signing completed via deep link"); + startScan(); + return; } } catch (error) { console.error("Error signing vote:", error); @@ -995,6 +985,7 @@ export function createScanLogic({ } async function handleBlindVote() { + const globalState = await mustGlobalState(); console.log("🔍 DEBUG: handleBlindVote called"); const currentSelectedOption = get(selectedBlindVoteOption); const currentSigningData = get(signingData); @@ -1241,6 +1232,7 @@ export function createScanLogic({ } async function handleRevealVote() { + const globalState = await mustGlobalState(); const currentPollId = get(revealPollId); if (!currentPollId) return; @@ -1392,8 +1384,12 @@ export function createScanLogic({ console.log("Redirect:", data.redirect); console.log("Redirect URI:", data.redirect_uri); - // Ensure globalState is available - if (!globalState) { + // Wait for global state rather than discarding the request. This + // runs on a payload that has already been consumed and cleared, so + // returning early here loses the login outright — and arriving + // before the root layout has published global state is exactly what + // happens when a deep link restores this page on a fresh webview. + if (!(await requireGlobalState())) { console.error( "GlobalState not available, cannot handle deep link", ); @@ -1619,8 +1615,21 @@ export function createScanLogic({ async function initialize() { console.log("Scan QR page mounted, checking authentication..."); + // Wait for global state rather than assuming the layout got there + // first. This page is a deep-link destination, so it can be restored + // as the current route on a fresh webview load and mount before the + // root layout has created it. + const gs = await requireGlobalState(); + if (!gs) { + console.log( + "[SCAN] global state never became available, redirecting to login", + ); + await goto("/login"); + return () => {}; + } + try { - const vault = await globalState.vaultController.vault; + const vault = await gs.vaultController.vault; if (!vault) { console.log("User not authenticated, redirecting to login"); await goto("/login"); @@ -1630,9 +1639,16 @@ export function createScanLogic({ "User authenticated, proceeding with scan functionality", ); } catch (error) { - console.log("Authentication check failed, redirecting to login"); - await goto("/login"); - return () => {}; + // A THROWN read is "unknown", not "signed out" — the store IPC is + // briefly unavailable after a resume. Treating it as signed out is + // what dropped the user on the PIN screen mid deep-link login, and + // it discards the very distinction readVaultResilient preserves. + // Stay put and let the consent flow below continue; the (app) + // layout guard still covers a genuinely unauthenticated visitor. + console.warn( + "[SCAN] vault read failed, continuing without bouncing to login:", + error, + ); } console.log("Scan QR page mounted, checking for deep link data..."); @@ -1666,9 +1682,52 @@ export function createScanLogic({ window.addEventListener("deepLinkAuth", authHandler); window.addEventListener("deepLinkSign", signHandler); - let deepLinkData = sessionStorage.getItem("deepLinkData"); - if (!deepLinkData) { - deepLinkData = sessionStorage.getItem("pendingDeepLink"); + // Listeners are registered ABOVE this await on purpose: a URL that + // arrives while we wait is then delivered by event, and the storage + // read below is the fallback for one that arrived before we mounted. + // + // Without this wait the page loses the race outright on the common + // path. Opening a w3ds link from the browser restarts the Activity, so + // a fresh webview mounts this route immediately while the root layout + // is still asynchronously importing the plugin and calling getCurrent(). + // We would read empty storage, log "No deep link data found", start the + // camera, and only then would the payload be stored — with nobody left + // to act on it. + if (initialDeepLinkReady) { + try { + // Bounded: the layout resolves this in a finally, but a hang + // in the plugin import must degrade to "no deep link" rather + // than leaving this page blank forever. + await Promise.race([ + initialDeepLinkReady, + new Promise((resolve) => setTimeout(resolve, 3000)), + ]); + } catch { + // The layout resolves this in a finally; a rejection here must + // not stop the page from working. + } + } + + const deepLinkData = peekDeepLinkPayload(); + + // A login approved just before the app handed off to the browser. If + // the Activity was recreated on the way back, the drawer state was + // destroyed with the old webview, so restore the confirmation here + // rather than dropping the user on a bare scanner page. + const completed = takeCompletedDeepLink(); + if (completed && !deepLinkData) { + console.log("Restoring post-login confirmation after app restart"); + if (completed.platform) platform.set(completed.platform); + // The app icon is resolved from the hostname, so restoring the + // name alone rendered the card with a blank logo. + if (completed.hostname) hostname.set(completed.hostname); + if (completed.redirect) redirect.set(completed.redirect); + loggedInDrawerOpen.set(true); + return () => { + window.removeEventListener("deepLinkReceived", deepLinkHandler); + window.removeEventListener("deepLinkAuth", authHandler); + window.removeEventListener("deepLinkSign", signHandler); + }; } if (deepLinkData) { @@ -1680,9 +1739,22 @@ export function createScanLogic({ } catch (error) { console.error("Error parsing deep link data:", error); } finally { - sessionStorage.removeItem("deepLinkData"); - sessionStorage.removeItem("pendingDeepLink"); + // Clear only after the payload has been turned into an open + // drawer. handleDeepLinkData is synchronous up to the point + // where it sets the drawer store, so by here the consent UI + // is already committed and the keys are safe to drop. Doing + // this earlier meant a re-mount of /scan-qr (which a racing + // navigation can trigger on a cold start) found nothing left + // to show and silently fell through to the camera. + clearDeepLinkFlow(); } + } else if (wasDeepLinkJustAcknowledged()) { + // The Activity restart that this login's own openUrl set in motion + // landed after the user had already tapped Ok. There is nothing + // left to show, but starting the camera would hand them a scanner + // they never asked for. Return home instead. + console.log("Deep link already acknowledged, returning to main"); + await goto("/main"); } else { console.log("No deep link data found, starting normal scanning"); startScan(); diff --git a/infrastructure/eid-wallet/src/routes/(app)/settings/+page.svelte b/infrastructure/eid-wallet/src/routes/(app)/settings/+page.svelte index 52621e2c0..7c2956e23 100644 --- a/infrastructure/eid-wallet/src/routes/(app)/settings/+page.svelte +++ b/infrastructure/eid-wallet/src/routes/(app)/settings/+page.svelte @@ -10,6 +10,7 @@ import { import { clearAllNotifications } from "$lib/stores/notifications"; import { BottomSheet, ButtonAction } from "$lib/ui"; import { PinIcon, PrivacyIcon } from "$lib/ui/icons"; +import { resetAuthSession } from "$lib/utils/deepLinkFlow"; import { clearAllCachedPhotos } from "$lib/utils/photoCache"; import { isPermissionGranted } from "@choochmeque/tauri-plugin-notifications-api"; import { FaceIdIcon, Notification02Icon } from "@hugeicons/core-free-icons"; @@ -84,6 +85,11 @@ async function performLogout() { isLogoutDrawerOpen = false; clearAllNotifications(); await clearAllCachedPhotos(); + // Drop the session's authentication markers and any half-finished deep + // link. goto("/") below is an SPA navigation, so sessionStorage would + // otherwise survive the logout and keep reporting this session as + // authenticated to the deep-link router. + resetAuthSession(); if (!globalState) { console.error("Cannot logout: global state not ready"); return; @@ -191,4 +197,3 @@ $effect(() => { > - diff --git a/infrastructure/eid-wallet/src/routes/(auth)/+layout.svelte b/infrastructure/eid-wallet/src/routes/(auth)/+layout.svelte index bbbc612f4..b4da3a490 100644 --- a/infrastructure/eid-wallet/src/routes/(auth)/+layout.svelte +++ b/infrastructure/eid-wallet/src/routes/(auth)/+layout.svelte @@ -2,7 +2,11 @@ import { goto } from "$app/navigation"; import { page } from "$app/state"; import type { GlobalState } from "$lib/global"; -import { getContext, onMount } from "svelte"; +import { + isWalletAuthenticated, + shouldAbortStaleContinuation, +} from "$lib/utils/deepLinkFlow"; +import { getContext, onDestroy, onMount } from "svelte"; let { children } = $props(); let isChecking = $state(true); @@ -10,6 +14,19 @@ let vaultExists = $state(false); let guardFailed = $state(false); const getGlobalState = getContext<() => GlobalState>("globalState"); +let destroyed = false; + +onDestroy(() => { + destroyed = true; +}); + +// See /login: guards must not treat "mounted while already authenticated" as +// stale, only a transition that happens while they wait. +const authenticatedAtStart = isWalletAuthenticated(); + +function superseded(): boolean { + return shouldAbortStaleContinuation(destroyed, authenticatedAtStart); +} onMount(async () => { try { @@ -20,9 +37,11 @@ onMount(async () => { let retries = 0; while (!globalState && retries < 50) { await new Promise((r) => setTimeout(r, 100)); + if (superseded()) return; globalState = getGlobalState(); retries++; } + if (superseded()) return; if (!globalState) { console.error("Global state is not defined"); guardFailed = true; @@ -31,6 +50,7 @@ onMount(async () => { // Check if user is already authenticated const vault = await globalState.vaultController.vault; + if (superseded()) return; const isLoginPage = page.url.pathname === "/login"; console.log( "[AUTH GUARD] path:", @@ -47,9 +67,11 @@ onMount(async () => { } const onboardingComplete = await globalState.isOnboardingComplete; + if (superseded()) return; console.log("[AUTH GUARD] onboardingComplete:", onboardingComplete); if (onboardingComplete) { const pinHash = await globalState.securityController.pinHash; + if (superseded()) return; const isAlreadyAtLogin = page.url.pathname === "/login"; console.log( "[AUTH GUARD] pinHash:", diff --git a/infrastructure/eid-wallet/src/routes/(auth)/login/+page.svelte b/infrastructure/eid-wallet/src/routes/(auth)/login/+page.svelte index 724448e3b..6ccaae0d0 100644 --- a/infrastructure/eid-wallet/src/routes/(auth)/login/+page.svelte +++ b/infrastructure/eid-wallet/src/routes/(auth)/login/+page.svelte @@ -4,19 +4,17 @@ import { keyboardInset } from "$lib/actions/keyboardInset"; import type { GlobalState } from "$lib/global"; 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"; + beginAuthPrompt, + endAuthPrompt, + isDeepLinkFlowActive, + isWalletAuthenticated, + shouldAbortStaleContinuation, +} from "$lib/utils/deepLinkFlow"; +import { continueAfterSuccessfulAuth } from "$lib/utils/postLogin"; +import { getContext, onDestroy, 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,16 +34,27 @@ 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, -}; +// An async onMount is not cancelled by unmounting. This one polls for global +// state and then awaits two plugin calls before prompting, so it can still be +// suspended after the splash's own biometric prompt succeeded and routed the +// user onward. Waking up then would fire a SECOND native authenticate() over +// the consent screen and, on success, run a second post-auth routine that +// navigates away from it. +let destroyed = false; +onDestroy(() => { + destroyed = true; +}); + +// Snapshot taken when this screen mounts. Reaching /login while the session is +// ALREADY authenticated is normal (a deep-link login authenticates, then a +// guard bounces here), and must still offer biometrics. Only a change from +// unauthenticated to authenticated while we were waiting means another screen +// took over. +const authenticatedAtStart = isWalletAuthenticated(); + +function superseded(): boolean { + return shouldAbortStaleContinuation(destroyed, authenticatedAtStart); +} async function clearPin() { if (isPostAuthLoading) return; @@ -61,6 +70,9 @@ async function verifyAndAdvance(currentPin: string) { isError = false; isPostAuthLoading = true; + // A deep link arriving mid-verification must not issue its own + // navigation; continueAfterSuccessfulAuth below owns where we go next. + beginAuthPrompt(); try { const ok = await globalState.securityController.verifyPin(currentPin); if (!ok) { @@ -69,12 +81,16 @@ async function verifyAndAdvance(currentPin: string) { return; } + // Bracket stays open: continueAfterSuccessfulAuth releases it once it + // has collected any pending deep link. await continueAfterSuccessfulAuth(globalState); } catch (e) { console.error("PIN verification failed", e); isError = true; pin = ""; } finally { + // Idempotent — already released on the success path. + endAuthPrompt(); isPostAuthLoading = false; } } @@ -90,9 +106,11 @@ onMount(async () => { let retries = 0; while (!gs && retries < 50) { await new Promise((r) => setTimeout(r, 100)); + if (superseded()) return; gs = getGlobalState(); retries++; } + if (superseded()) return; if (!gs) { console.error("Global state never became available"); await goto("/"); @@ -100,36 +118,17 @@ 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; - } - } + // Sticky flow flag, not the raw key: the payload may already have been + // promoted from pendingDeepLink to deepLinkData by the time we mount. + hasPendingDeepLink = isDeepLinkFlowActive(); + + // NOTE: this screen deliberately never calls authenticate(). Biometrics + // are prompted exclusively from the splash, which only routes here once + // that prompt has been declined, has failed, or was never available. A + // second prompt site is what made the dialog's placement non-deterministic + // — whichever screen won the mount race decided whether the system dialog + // appeared over the purple splash or over a half-painted PIN pad. /login + // is now purely the PIN fallback. }); diff --git a/infrastructure/eid-wallet/src/routes/+layout.svelte b/infrastructure/eid-wallet/src/routes/+layout.svelte index fd1271a54..4fe571505 100644 --- a/infrastructure/eid-wallet/src/routes/+layout.svelte +++ b/infrastructure/eid-wallet/src/routes/+layout.svelte @@ -1,5 +1,5 @@ process.env[key] || dotEnv[key] || fallback; + +const REGISTRY_URL = env("PUBLIC_REGISTRY_URL", "http://localhost:4321"); +const TOKEN = env("PUBLIC_EID_WALLET_TOKEN", ""); + +const argv = process.argv.slice(2); +const arg = (name, fallback) => { + const i = argv.indexOf(`--${name}`); + return i >= 0 && argv[i + 1] ? argv[i + 1] : fallback; +}; + +const rawEname = arg("ename"); +const fullName = arg("name", "Julien Connault"); + +if (!rawEname) { + console.error( + "Usage: node scripts/seed-legal-id.mjs --ename @your-ename [--name \"Full Name\"]", + ); + process.exit(1); +} + +const at = (e) => (e.startsWith("@") ? e : `@${e}`); +const SELF = at(rawEname); + +/** Mirror of evault-core's stableStringify (binding-document-hash.ts:7). */ +function stableStringify(value) { + if (value === null || typeof value !== "object") return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`; + const keys = Object.keys(value).sort(); + return `{${keys + .map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`) + .join(",")}}`; +} + +function docHash(subject, type, data) { + return createHash("sha256") + .update(Buffer.from(stableStringify({ subject, type, data }), "utf8")) + .digest("hex"); +} + +const CREATE_BINDING_DOC = ` + mutation CreateBindingDoc($input: CreateBindingDocumentInput!) { + createBindingDocument(input: $input) { + metaEnvelopeId + errors { message code } + } + } +`; + +async function main() { + const resolveUrl = new URL( + `resolve?w3id=${encodeURIComponent(SELF)}`, + REGISTRY_URL, + ).toString(); + const resolveRes = await fetch(resolveUrl); + if (!resolveRes.ok) { + throw new Error(`registry resolve -> HTTP ${resolveRes.status}`); + } + const { uri } = await resolveRes.json(); + if (!uri) throw new Error(`registry returned no uri for ${SELF}`); + const gqlUrl = new URL("/graphql", uri).toString(); + + console.log(`registry: ${REGISTRY_URL}`); + console.log(`vault: ${uri}`); + console.log(`target: ${SELF}`); + console.log(`name: ${fullName}\n`); + + // Exactly the three keys validateBindingDocumentData keeps. No `kind`. + const data = { + vendor: "didit", + reference: randomUUID(), + name: fullName, + }; + + const res = await fetch(gqlUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-ENAME": SELF, + ...(TOKEN ? { Authorization: `Bearer ${TOKEN}` } : {}), + }, + body: JSON.stringify({ + query: CREATE_BINDING_DOC, + variables: { + input: { + subject: SELF, + type: "id_document", + data, + ownerSignature: { + signer: SELF, + signature: docHash(SELF, "id_document", data), + timestamp: new Date().toISOString(), + }, + }, + }, + }), + }); + + const text = await res.text(); + let json; + try { + json = JSON.parse(text); + } catch { + throw new Error(`HTTP ${res.status}: ${text.slice(0, 300)}`); + } + if (json.errors?.length) { + throw new Error(json.errors.map((e) => e.message).join("; ")); + } + const result = json.data?.createBindingDocument; + if (result?.errors?.length) { + throw new Error(result.errors.map((e) => e.message).join("; ")); + } + if (!result?.metaEnvelopeId) { + throw new Error(`no metaEnvelopeId returned: ${text.slice(0, 300)}`); + } + + console.log(`Created id_document ${result.metaEnvelopeId}`); + console.log( + "\nReload the wallet home: Legal ID should populate and the eName badge\n" + + "should read VERIFIED (legalId !== null is enough — isFake stays true).", + ); +} + +main().catch((err) => { + console.error(`\nFatal: ${err.message}`); + process.exit(1); +}); diff --git a/scripts/seed-social-bindings.mjs b/scripts/seed-social-bindings.mjs new file mode 100644 index 000000000..f30e33799 --- /dev/null +++ b/scripts/seed-social-bindings.mjs @@ -0,0 +1,409 @@ +#!/usr/bin/env node +/** + * Seed N social bindings onto an existing eVault, for testing the Social + * Bindings screens (issues #1080 / #1086) without scanning N QR codes. + * + * Each seeded binding mirrors exactly what a real scan produces, so the + * screens hit the same code paths (including the per-binding reconcile that + * makes the Full List slow): + * + * 1. a real anonymous eVault is provisioned for the counterparty, so the + * registry resolves it and cross-vault reads actually go over the wire; + * 2. a `self` doc gives it a display name, and a `photograph` doc gives it a + * photo blob — the Full List drags those blobs across the network because + * it calls fetchNameFromVault without { nameOnly: true }, so a seed with + * no photos would under-report the latency badly; + * 3. the primary `social_connection` doc lands in the counterparty's vault + * (subject=@them, signed by you), then they counter-sign it -> confirmed; + * 4. a single-signature mirror lands in your vault (subject=@you, signed by + * you) -> role "sent", which is what triggers the remote reconcile. + * + * Signatures use the SHA-256-of-canonical-form path that the server accepts as + * a legacy signature (BindingDocumentService.ts:271), so no keypair is needed. + * + * Usage: + * node scripts/seed-social-bindings.mjs --ename @your-ename [--count 8] + * [--photo-kb 250] [--no-photos] + * + * Reads PUBLIC_REGISTRY_URL / PUBLIC_PROVISIONER_URL / PUBLIC_EID_WALLET_TOKEN + * from the repo-root .env; each can be overridden by a real env var. + */ + +import { createHash, randomBytes, randomUUID } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { dirname, resolve as resolvePath } from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = resolvePath(dirname(fileURLToPath(import.meta.url)), ".."); + +// Skips the whole KYC block server-side (ProvisioningService.ts:474). +const DEMO_VERIFICATION_CODE = "d66b7138-538a-465f-a6ce-f6985854c3f4"; + +const FAKE_NAMES = [ + "Ada Lovelace", + "Grace Hopper", + "Alan Turing", + "Katherine Johnson", + "Linus Torvalds", + "Margaret Hamilton", + "Dennis Ritchie", + "Barbara Liskov", + "Ken Thompson", + "Radia Perlman", + "Tim Berners-Lee", + "Anita Borg", +]; + +const RELATIONS = [ + "Met at a conference", + "Colleague", + "Friend", + "Met at a meetup", + "Family", + "Business contact", +]; + +// --- env ---------------------------------------------------------------- + +function loadDotEnv(path) { + const out = {}; + let raw; + try { + raw = readFileSync(path, "utf8"); + } catch { + return out; + } + for (const line of raw.split("\n")) { + const m = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/.exec(line); + if (!m) continue; + let v = m[2]; + if ( + (v.startsWith('"') && v.endsWith('"')) || + (v.startsWith("'") && v.endsWith("'")) + ) { + v = v.slice(1, -1); + } + out[m[1]] = v; + } + return out; +} + +const dotEnv = loadDotEnv(resolvePath(ROOT, ".env")); +const env = (key, fallback) => process.env[key] || dotEnv[key] || fallback; + +const REGISTRY_URL = env("PUBLIC_REGISTRY_URL", "http://localhost:4321"); +const PROVISIONER_URL = env("PUBLIC_PROVISIONER_URL", "http://localhost:3001"); +const TOKEN = env("PUBLIC_EID_WALLET_TOKEN", ""); + +// --- args --------------------------------------------------------------- + +const argv = process.argv.slice(2); +function arg(name, fallback) { + const i = argv.indexOf(`--${name}`); + return i >= 0 && argv[i + 1] ? argv[i + 1] : fallback; +} + +const rawEname = arg("ename"); +const count = Number.parseInt(arg("count", "8"), 10); +const withPhotos = !argv.includes("--no-photos"); +const photoKb = Number.parseInt(arg("photo-kb", "250"), 10); + +if (!rawEname) { + console.error( + "Usage: node scripts/seed-social-bindings.mjs --ename @your-ename [--count 8]\n\n" + + "Find your eName in the wallet (eName card), or in the Safari Web Inspector\n" + + "console — it looks like @a56dfc50-a3ba-5828-ab64-47194a27f1e6.", + ); + process.exit(1); +} +if (!Number.isInteger(count) || count < 1) { + console.error(`--count must be a positive integer, got: ${arg("count")}`); + process.exit(1); +} +if (withPhotos && (!Number.isInteger(photoKb) || photoKb < 1)) { + console.error(`--photo-kb must be a positive integer, got: ${arg("photo-kb")}`); + process.exit(1); +} + +const at = (e) => (e.startsWith("@") ? e : `@${e}`); +const SELF = at(rawEname); + +// --- primitives --------------------------------------------------------- + +/** Mirror of evault-core's stableStringify (binding-document-hash.ts:7). */ +function stableStringify(value) { + if (value === null || typeof value !== "object") return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`; + const keys = Object.keys(value).sort(); + return `{${keys + .map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`) + .join(",")}}`; +} + +/** + * The server recomputes this over the *validated* data and accepts an exact + * match as a valid signature, so the payload must carry exactly the keys + * validateBindingDocumentData returns — no more, no less. + */ +function docHash(subject, type, data) { + return createHash("sha256") + .update(Buffer.from(stableStringify({ subject, type, data }), "utf8")) + .digest("hex"); +} + +async function gql(gqlUrl, eName, query, variables) { + const res = await fetch(gqlUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-ENAME": eName, + ...(TOKEN ? { Authorization: `Bearer ${TOKEN}` } : {}), + }, + body: JSON.stringify({ query, variables }), + }); + const text = await res.text(); + let json; + try { + json = JSON.parse(text); + } catch { + throw new Error(`${gqlUrl} -> HTTP ${res.status}: ${text.slice(0, 300)}`); + } + if (json.errors?.length) { + throw new Error(json.errors.map((e) => e.message).join("; ")); + } + return json.data; +} + +const CREATE_BINDING_DOC = ` + mutation CreateBindingDoc($input: CreateBindingDocumentInput!) { + createBindingDocument(input: $input) { + metaEnvelopeId + errors { message code } + } + } +`; + +const ADD_SIGNATURE = ` + mutation AddSignature($input: CreateBindingDocumentSignatureInput!) { + createBindingDocumentSignature(input: $input) { + bindingDocument { subject signatures { signer } } + errors { message code } + } + } +`; + +async function createBindingDoc(gqlUrl, vaultEname, subject, type, data, signer) { + const payload = await gql(gqlUrl, vaultEname, CREATE_BINDING_DOC, { + input: { + subject, + type, + data, + ownerSignature: { + signer, + signature: docHash(subject, type, data), + timestamp: new Date().toISOString(), + }, + }, + }); + const result = payload.createBindingDocument; + if (result.errors?.length) { + throw new Error(result.errors.map((e) => e.message).join("; ")); + } + if (!result.metaEnvelopeId) { + throw new Error(`createBindingDocument(${type}) returned no metaEnvelopeId`); + } + return result.metaEnvelopeId; +} + +async function counterSign(gqlUrl, vaultEname, docId, subject, type, data, signer) { + const payload = await gql(gqlUrl, vaultEname, ADD_SIGNATURE, { + input: { + bindingDocumentId: docId, + signature: { + signer, + signature: docHash(subject, type, data), + timestamp: new Date().toISOString(), + }, + }, + }); + const result = payload.createBindingDocumentSignature; + if (result.errors?.length) { + throw new Error(result.errors.map((e) => e.message).join("; ")); + } +} + +async function resolveVaultUri(ename) { + const url = new URL( + `resolve?w3id=${encodeURIComponent(ename)}`, + REGISTRY_URL, + ).toString(); + const res = await fetch(url); + if (!res.ok) { + throw new Error( + `registry resolve ${ename} -> HTTP ${res.status} (${url})`, + ); + } + const json = await res.json(); + if (!json?.uri) throw new Error(`registry returned no uri for ${ename}`); + return json.uri; +} + +async function provisionVault() { + const entropyRes = await fetch(new URL("/entropy", REGISTRY_URL).toString()); + if (!entropyRes.ok) { + throw new Error(`registry /entropy -> HTTP ${entropyRes.status}`); + } + const { token: registryEntropy } = await entropyRes.json(); + if (!registryEntropy) throw new Error("registry /entropy returned no token"); + + const res = await fetch(new URL("/provision", PROVISIONER_URL).toString(), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + registryEntropy, + namespace: randomUUID(), + verificationId: DEMO_VERIFICATION_CODE, + }), + }); + const text = await res.text(); + let json; + try { + json = JSON.parse(text); + } catch { + throw new Error(`provisioner -> HTTP ${res.status}: ${text.slice(0, 300)}`); + } + if (!json.success || !json.w3id || !json.uri) { + throw new Error(`provision failed: ${text.slice(0, 300)}`); + } + return { ename: at(json.w3id), uri: json.uri }; +} + +// --- main --------------------------------------------------------------- + +async function seedOne(index, selfGqlUrl) { + const name = FAKE_NAMES[index % FAKE_NAMES.length]; + const suffix = index >= FAKE_NAMES.length ? ` ${Math.floor(index / FAKE_NAMES.length) + 1}` : ""; + const displayName = `${name}${suffix}`; + const relation = RELATIONS[index % RELATIONS.length]; + + const peer = await provisionVault(); + const peerGqlUrl = new URL("/graphql", peer.uri).toString(); + + // Display name, so the list shows a person instead of a raw eName. + const selfData = { kind: "self", name: displayName }; + await createBindingDoc( + peerGqlUrl, + peer.ename, + peer.ename, + "self", + selfData, + peer.ename, + ); + + // A photo blob, because the Full List pulls every doc type from each + // counterparty vault. Random bytes: incompressible, like a real JPEG. + if (withPhotos) { + const photoData = { + photoBlob: randomBytes(Math.ceil((photoKb * 1024 * 3) / 4)).toString( + "base64", + ), + description: "Seeded portrait", + }; + await createBindingDoc( + peerGqlUrl, + peer.ename, + peer.ename, + "photograph", + photoData, + peer.ename, + ); + } + + // Primary doc: lives in the counterparty's vault, signed by us first. + const primaryData = { + kind: "social_connection", + name: displayName, + parties: [SELF, peer.ename], + relation_description: relation, + }; + const primaryId = await createBindingDoc( + peerGqlUrl, + peer.ename, + peer.ename, + "social_connection", + primaryData, + SELF, + ); + + // They counter-sign -> 2 signatures -> the binding reads as confirmed. + await counterSign( + peerGqlUrl, + peer.ename, + primaryId, + peer.ename, + "social_connection", + primaryData, + peer.ename, + ); + + // Our single-signature mirror. This is what puts the binding in our list, + // with role "sent" -> reconciled against their vault on every load. + const mirrorData = { + kind: "social_connection", + name: displayName, + parties: [SELF, peer.ename], + relation_description: relation, + }; + await createBindingDoc( + selfGqlUrl, + SELF, + SELF, + "social_connection", + mirrorData, + SELF, + ); + + return { displayName, ename: peer.ename }; +} + +async function main() { + console.log(`registry: ${REGISTRY_URL}`); + console.log(`provisioner: ${PROVISIONER_URL}`); + console.log(`token: ${TOKEN ? "present" : "MISSING (writes will fail)"}`); + console.log(`target: ${SELF}`); + console.log(`count: ${count}`); + console.log( + `photos: ${withPhotos ? `${photoKb} KB per counterparty` : "disabled"}\n`, + ); + + const selfUri = await resolveVaultUri(SELF); + const selfGqlUrl = new URL("/graphql", selfUri).toString(); + console.log(`Resolved your vault -> ${selfUri}\n`); + + const seeded = []; + for (let i = 0; i < count; i++) { + const label = `[${i + 1}/${count}]`; + try { + const { displayName, ename } = await seedOne(i, selfGqlUrl); + seeded.push({ displayName, ename }); + console.log(`${label} ${displayName.padEnd(20)} ${ename}`); + } catch (err) { + console.error(`${label} FAILED: ${err.message}`); + } + } + + console.log( + `\nSeeded ${seeded.length}/${count} social bindings onto ${SELF}.`, + ); + if (seeded.length) { + console.log( + "Open the wallet -> Social Bindings -> Full List. Each contact costs a\n" + + "registry resolve + a paginated read of their vault + a name lookup.", + ); + } +} + +main().catch((err) => { + console.error(`\nFatal: ${err.message}`); + process.exit(1); +});