S290 Desktop Auth Exchange Technical
Documentation for Sessions
S290: Server-Side Token Exchange for Desktop Auth — Technical Documentation
Session: 290.1 Branch:
session-280-290Date: 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
POST /api/auth/desktop-exchange— deposits{ sid, token, user }GET /api/auth/desktop-exchange?sid=...— retrieves and deletes entry- In-memory
Map<string, ExchangeEntry>withexpiresAt: Date.now() + 180_000 - Lazy cleanup: iterates Map on each GET, deletes expired entries
- UUID v4 validation on
sidparameter (regex withiflag) - Rate limiting: POST 10/min, GET 60/min (per IP via ThrottlerGuard)
@Public()— no auth required (the sid IS the secret — 122 bits of entropy)@UseGuards(ThrottlerGuard)— global rate limiting protection
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:
- Inject
ActivatedRouteandDesktopAuthExchangeService - Read
sidfromroute.snapshot.queryParamMap.get('sid')inngOnInit - New
depositTokenIfExchange()method: ifsidpresent, POST token to exchange - Called from both
ngOnInit(pre-authenticated) andredirectToVitto()(post-login) exchangePostedsignal prevents duplicate POSTs (set innextcallback, not before subscribe)- If
sidis missing: falls back to deep-link-only (backward compatible with pre-S290 desktop builds)
4. DesktopWelcomeComponent (MODIFIED)
File: apps/gograb-ai/src/app/pages/desktop-welcome/desktop-welcome.component.ts
Changes:
- Inject
DesktopAuthExchangeService signIn(): generatessid, appends?sid={sid}to auth URL- New
createDeepLinkObservable(): wraps Taurilisten()into Observable with promise-based teardown - New
startAuthRace(sid): merges deep link Observable and polling,take(1) + timeout(120s) - Removed: old
listenForTauriAuthToken()for desktop (replaced by Observable in race) - Removed:
setTimeoutfor auth timeout (replaced by RxJStimeoutoperator) - Removed:
unlistenAuthTokenfield,clearAuthTimeout()method,authTimeoutIdfield (dead code) - Preserved: mobile (Capacitor) listeners unchanged — mobile doesn't participate in the race
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
- User closes browser before login — polling times out after 120s, shows clear error
- Backend is down — POST fails, but deep link may still work. Error only if both fail
- Multiple signIn() clicks — each generates new sid. Old sids expire in 3 minutes
- Deep link AND poll both deliver —
take(1)ensures only first is processed - Old desktop build (no sid) — browser page falls back to deep-link-only
- Non-Tauri environment —
createDeepLinkObservablecatches import failure, returns no-op teardown, lets polling handle it - Network blip on POST —
exchangePostedflag not set, retry is possible - Poll exhaustion with no response —
throwIfEmptysurfaces error instead of silent completion
Testing Checklist
- Build Tauri app:
tauri build --debug, install in/Applications - Test GoGrab tab → browser opens → login → app receives token via polling
- Test with deep link working (verify race resolution — first wins)
- Test with deep link broken (unregister URL scheme) → verify polling alone works
- Test timeout: open browser, don't login, wait 120s → verify clean error
- Test concurrent: click "Sign in" twice rapidly → verify no double-login
- Test pre-authenticated: if already logged in on browser → token deposited + deep link shown
- Test backward compat: load /auth/desktop WITHOUT ?sid= → verify deep-link-only works
- Test email/phone tabs still work independently
- Verify backend logs:
Token deposited for sid=...,Token retrieved for sid=...
Next Steps
- Build & test on real Tauri app (all scenarios in checklist)
- Deploy backend to Railway (new controller auto-registers, no config changes needed)
- Monitor exchange endpoint usage in backend logs
- Future: Redis migration when backend scales horizontally (change Map to Redis SETEX)
- Future: Mobile polling — consider adding exchange fallback for Capacitor if deep link issues emerge