S290 Desktop Auth Exchange Technical

Documentation for Sessions

S290: Server-Side Token Exchange for Desktop Auth — Technical Documentation

Session: 290.1 Branch: session-280-290 Date: 2026-03-30 Status: Implementation complete, pending build/test/deploy Depends on: S288 (deep link fixes) — no blocking dependency

Problem

Vitto Desktop auth relies entirely on macOS deep links (vitto://auth?token=...) to transfer tokens from the system browser back to the Tauri app. Deep links have 5 documented failure modes (browser gesture blocking, Gatekeeper, Info.plist misconfiguration, dev mode, user dismissal). S288 improved the deep link path but deep links remain inherently fragile on desktop. Production-grade desktop apps (VS Code, Slack, 1Password) all use server-side token exchange as primary or fallback mechanism.

Solution

Architecture

                         ┌─────────────────────────────────────┐
                         │     Backend API (port 3000)          │
                         │                                     │
                         │  POST /api/auth/desktop-exchange     │
                         │    → stores {sid, token, user}       │
                         │    → in-memory Map, 3-min TTL        │
                         │    → returns 201                     │
                         │                                     │
                         │  GET /api/auth/desktop-exchange?sid= │
                         │    → returns {token, user} if found  │
                         │    → 404 if not yet posted           │
                         │    → 410 if expired                  │
                         │    → deletes entry (one-time read)   │
                         └──────────┬──────────┬───────────────┘
                                    │          │
                    POST token      │          │  GET poll (every 2s)
                    after login     │          │  for up to 120s
                                    │          │
┌──────────────────────────┐   ┌────┴──────────┴────────────────┐
│  System Browser           │   │  Tauri Desktop App             │
│  /auth/desktop?sid={id}   │   │  DesktopWelcomeComponent       │
│                           │   │                                │
│  1. User logs in          │   │  1. Generate UUID v4 sid       │
│  2. depositTokenIfExchange│   │  2. Open browser with ?sid=    │
│     → POST to exchange    │   │  3. Start race:                │
│  3. Also try vitto:// link│   │     merge(deepLink$, poll$)    │
│  4. Show "close this tab" │   │       .pipe(take(1),           │
│                           │   │             timeout(120s))     │
└──────────────────────────┘   │  4. First to deliver wins       │
                                └────────────────────────────────┘

Race Resolution

// DesktopWelcomeComponent.startAuthRace()
const deepLink$ = createDeepLinkObservable();  // wraps Tauri event listener
const poll$ = exchangeService.pollForToken(sid);

merge(deepLink$, poll$).pipe(
  take(1),           // first emission wins, other is cancelled
  timeout(120_000),  // safety net if neither delivers
  takeUntil(destroy$),
).subscribe({
  next: handleAuthCallback,
  error: showTimeoutError,
});

Why merge + take(1) instead of race(): race() requires both sources to emit before selecting the winner. merge + take(1) takes the first emission from either source and immediately unsubscribes both. This is correct when one source (deep link) may never emit.

Implementation Details

1. DesktopExchangeController (NEW)

File: APIs/backend-api/src/app/auth/desktop-exchange.controller.ts

Known limitation: In-memory store fails in multi-instance deployments. Migration path to Redis SETEX is trivial when horizontal scaling is needed.

2. DesktopAuthExchangeService (NEW)

File: libs/libs-ui/auth-ui/src/lib/services/desktop-auth-exchange.service.ts

@Injectable({ providedIn: 'root' })
export class DesktopAuthExchangeService {
  generateSessionId(): string           // crypto.randomUUID()
  postToken(sid, token, user): Obs<void> // POST to exchange endpoint
  pollForToken(sid): Obs<{token, user}>  // timer(0, 2000) + switchMap + throwIfEmpty
}

Key RxJS pipeline for pollForToken:

timer(0, 2000)
  → take(60)                    // 60 attempts = 120 seconds
  → switchMap(http.get)         // each tick fires a GET
    → catchError(404 → EMPTY)   // not yet available, keep polling
    → catchError(410 → throw)   // expired, stop immediately
    → catchError(other → EMPTY) // transient error, retry next tick
  → take(1)                    // complete after first 200
  → throwIfEmpty(→ throw)      // error if all 60 attempts exhausted

3. DesktopAuthPageComponent (MODIFIED)

File: apps/gograb-ai/src/app/pages/desktop-auth/desktop-auth.component.ts

Changes:

4. DesktopWelcomeComponent (MODIFIED)

File: apps/gograb-ai/src/app/pages/desktop-welcome/desktop-welcome.component.ts

Changes:

Bugs Fixed During Code Review

1. Tauri Listener Leak (TOCTOU Race)

Problem: Original createDeepLinkObservable stored the unlisten function in a local variable (let unlisten = null). The Observable teardown captured the variable by reference. If teardown ran between when listen() resolved and the .then() assigned to unlisten, the closure saw null and skipped cleanup. The Tauri native listener leaked.

Fix: Capture the entire promise chain as unlistenPromise. In teardown, call unlistenPromise.then(fn => fn()). The .catch() returns a no-op function so the promise always resolves to a callable.

2. Silent Poll Exhaustion

Problem: When all 60 poll attempts returned 404 (EMPTY), timer completed, switchMap propagated completion, take(1) completed without emitting. The merge didn't complete (deep link Observable never completes). UI stuck on "Waiting for browser..." with no error message and no recovery path.

Fix: Added throwIfEmpty(() => new Error('Token exchange timed out')) after take(1). This converts silent completion to an error that propagates through merge to the error handler in startAuthRace().

3. exchangePosted Blocking Retries

Problem: exchangePosted.set(true) was called before subscribe(). If the POST failed (network error), the flag was already set, permanently blocking retries.

Fix: Moved to next callback — flag is only set on successful POST (HTTP 201).

Files Changed

File Action Description
APIs/backend-api/src/app/auth/desktop-exchange.controller.ts CREATE Exchange endpoint (POST deposit + GET retrieve)
APIs/backend-api/src/app/backend-auth/backend-auth.module.ts MODIFY Register DesktopExchangeController
libs/libs-ui/auth-ui/src/lib/services/desktop-auth-exchange.service.ts CREATE Client-side exchange service (postToken + pollForToken)
libs/libs-ui/auth-ui/src/index.ts MODIFY Export DesktopAuthExchangeService
apps/gograb-ai/src/app/pages/desktop-auth/desktop-auth.component.ts MODIFY Read sid from query params, POST token on auth success
apps/gograb-ai/src/app/pages/desktop-welcome/desktop-welcome.component.ts MODIFY Generate sid, race deep link vs polling

MCP Intelligence Captured

Type ID Description Confidence
Decision (architecture) dev_1774890142704_zqxad90 Server-side exchange racing with deep link 0.95
Decision (implementation) dev_1774890154307_rl2log6 merge+take(1)+timeout for racing Observables 0.93
Decision (bugfix) dev_1774890165464_ym4skmb throwIfEmpty to prevent silent poll exhaustion 0.96
Decision (bugfix) dev_1774890189499_1fa4hfz Promise-based teardown for Tauri listener 0.94
Decision (bugfix) dev_1774890199484_7bofn9w exchangePosted set on next, not before subscribe 0.92
Decision (implementation) dev_1774890207888_mwkw6pq In-memory Map over Redis for MVP 0.90
Convention (angular-rxjs) conv_...75tp5 Async callback APIs in Observables: capture teardown promise —
Convention (angular-rxjs) conv_...5ybyn Polling with throwIfEmpty after take(1) —
Convention (angular-signals) conv_...vpb4l Signal guards set on success, not before subscribe —
Convention (nestjs-auth) conv_...i0qu4 Desktop exchange endpoint security model —
Reflection reflection-1774890255212 6 lessons learned —
Calibration desktop-auth domain 0.95 raw → 0.95 calibrated —

Edge Cases Handled

  1. User closes browser before login — polling times out after 120s, shows clear error
  2. Backend is down — POST fails, but deep link may still work. Error only if both fail
  3. Multiple signIn() clicks — each generates new sid. Old sids expire in 3 minutes
  4. Deep link AND poll both deliver — take(1) ensures only first is processed
  5. Old desktop build (no sid) — browser page falls back to deep-link-only
  6. Non-Tauri environment — createDeepLinkObservable catches import failure, returns no-op teardown, lets polling handle it
  7. Network blip on POST — exchangePosted flag not set, retry is possible
  8. Poll exhaustion with no response — throwIfEmpty surfaces error instead of silent completion

Testing Checklist

Next Steps

  1. Build & test on real Tauri app (all scenarios in checklist)
  2. Deploy backend to Railway (new controller auto-registers, no config changes needed)
  3. Monitor exchange endpoint usage in backend logs
  4. Future: Redis migration when backend scales horizontally (change Map to Redis SETEX)
  5. Future: Mobile polling — consider adding exchange fallback for Capacitor if deep link issues emerge