S290 Desktop Auth Server Side Token Exchange

Documentation for Plans

S290: Vitto Desktop Auth — Server-Side Token Exchange Fallback

Status: Spec ready, not started Depends on: S288 (deep link fixes) — NO blocking dependency, can start independently Scope: Backend + Frontend (Tauri Angular app + backend-api)

Problem Statement

The Vitto Desktop auth flow relies entirely on macOS deep links (vitto://auth?token=...) to transfer authentication tokens from the system browser back to the Tauri app. Deep links have multiple failure modes:

  1. Browser blocks custom scheme navigation without a user gesture (Chrome, Safari)
  2. macOS Gatekeeper may block unsigned/untrusted apps from receiving URL scheme events
  3. Info.plist misconfiguration — if CFBundleURLTypes is missing or not merged during build, the scheme isn't registered
  4. App not in /Applications — Tauri deep links only work on bundled, installed apps (not tauri dev)
  5. User confusion — macOS may show a permission dialog ("Do you want to open Vitto?") that the user dismisses

S288 improves the deep link path (Info.plist, logging, user gesture fix, retry UX), but deep links remain inherently fragile on desktop. Production-grade desktop apps (VS Code, Slack, 1Password) all use a server-side token exchange as the primary or fallback mechanism.

Solution: Server-Side Token Exchange

Flow

┌─ Vitto Desktop (Tauri WebView) ──────────────────────────────┐
│                                                                │
│  1. Generate unique `authSessionId` (UUID v4)                  │
│  2. Open browser: ai.gograb.space/auth/desktop?sid={id}        │
│  3. Show "Waiting..." UI                                       │
│  4. Poll: GET /api/auth/desktop-exchange?sid={id}              │
│     every 2s for up to 120s                                    │
│  5. Response contains { token, user } → store & reload         │
│                                                                │
└────────────────────────────────────────────────────────────────┘

┌─ System Browser (ai.gograb.space/auth/desktop?sid=...) ───────┐
│                                                                │
│  1. User logs in (or is already authenticated)                 │
│  2. POST /api/auth/desktop-exchange { sid, token, user }       │
│  3. Show "You can close this tab" confirmation                 │
│  4. Also attempt vitto:// deep link as bonus (may work)        │
│                                                                │
└────────────────────────────────────────────────────────────────┘

┌─ Backend API (backend-api, port 3000) ────────────────────────┐
│                                                                │
│  POST /api/auth/desktop-exchange                               │
│    - Receives { sid, token, user }                             │
│    - Stores in Redis/memory with 3-min TTL                     │
│    - Returns 201                                               │
│                                                                │
│  GET /api/auth/desktop-exchange?sid={id}                       │
│    - Returns { token, user } if available                      │
│    - Returns 404 if not yet posted                             │
│    - Returns 410 if expired                                    │
│    - Deletes entry on successful read (one-time use)           │
│                                                                │
└────────────────────────────────────────────────────────────────┘

Why This Works

Implementation Details

1. Backend: DesktopExchangeController (NEW)

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

// POST /api/auth/desktop-exchange
// Body: { sid: string, token: string, user: string }
// - Validate sid is UUID format
// - Validate token is a valid JWT (basic structure check, not full verification — the token was already verified during login)
// - Store in in-memory Map with 3-minute TTL (no Redis needed for MVP — single backend instance)
// - Return 201 Created

// GET /api/auth/desktop-exchange?sid=...
// - Lookup sid in Map
// - If found: return { token, user }, DELETE entry (one-time read)
// - If not found: return 404
// - Garbage collection: clear entries older than 3 minutes on each request

Security considerations:

In-memory store vs Redis:

2. Frontend: DesktopAuthExchangeService (NEW)

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

@Injectable({ providedIn: 'root' })
export class DesktopAuthExchangeService {
  // generateSessionId(): string — UUID v4
  // postToken(sid: string): Observable<void> — browser side: POST token+user to exchange endpoint
  // pollForToken(sid: string): Observable<{ token: string; user: string }> — desktop side: poll every 2s, timeout 120s
}

Key behaviors:

3. Integration into DesktopWelcomeComponent (MODIFY)

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

Changes to signIn() method:

1. Generate authSessionId
2. Open browser to: {base}/auth/desktop?sid={authSessionId}
3. Start polling via DesktopAuthExchangeService.pollForToken(authSessionId)
4. Keep existing deep link listener (listenForTauriAuthToken) as parallel path
5. Whichever arrives first (deep link OR poll) wins — cancel the other

The deep link and polling are racing. Use RxJS race() or merge().pipe(take(1)) to handle whichever resolves first.

4. Integration into DesktopAuthPageComponent (MODIFY)

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

Changes:

1. Read `sid` from URL query params
2. After successful login (or if already authenticated):
   a. POST token to exchange endpoint via DesktopAuthExchangeService.postToken(sid)
   b. Also attempt vitto:// deep link (existing behavior, now secondary)
3. Show "You can close this tab" after successful POST

If sid is missing (legacy deep-link-only flow), fall back to current behavior (deep link only).

5. AuthService Changes — NONE

The AuthService is not modified. The exchange service is a parallel transport layer — it delivers the same { token, user } payload that the deep link does. The DesktopOAuthService.restoreFromDeepLinkPayload() method is reused to store the token.

Files to Create/Modify

File Action Description
APIs/backend-api/src/app/auth/desktop-exchange.controller.ts CREATE Exchange endpoint (POST + GET)
APIs/backend-api/src/app/auth/auth.module.ts MODIFY Register new controller
libs/libs-ui/auth-ui/src/lib/services/desktop-auth-exchange.service.ts CREATE Client-side exchange service
libs/libs-ui/auth-ui/src/index.ts MODIFY Export new service
apps/gograb-ai/src/app/pages/desktop-welcome/desktop-welcome.component.ts MODIFY Add polling fallback to signIn()
apps/gograb-ai/src/app/pages/desktop-auth/desktop-auth.component.ts MODIFY POST token to exchange on success

Edge Cases

  1. User closes browser before completing login — polling times out after 120s, shows error. Already handled by AUTH_TIMEOUT_MS.
  2. User completes login but backend is down — POST fails, but deep link may still work. Show error only if both fail.
  3. Multiple signIn() attempts — each generates a new sid. Old sids expire in 3 minutes. No conflict.
  4. Race condition: deep link AND poll both return — take(1) ensures only the first is processed. restoreFromDeepLinkPayload is idempotent (overwrites localStorage).
  5. sid guessing attack — UUID v4 = 2^122 possible values. At 60 req/min rate limit, brute force is computationally infeasible. 3-minute TTL further limits the window.

What NOT to Do

Testing Plan

  1. Build Tauri app (tauri build --debug), install in /Applications
  2. Test GoGrab tab → browser opens → login → app receives token via polling
  3. Test with deep link ALSO working (verify race resolution)
  4. Test with deep link broken (unregister URL scheme) → verify polling alone works
  5. Test timeout: open browser, don't login, wait 120s → verify clean error
  6. Test concurrent attempts: click "Sign in" twice rapidly → verify no double-login
  7. Test email/phone tabs still work independently

CORS Verification (Already Done in S288)

isTauriOrigin() in cors.factory.ts:112-114 allows tauri://localhost and https://tauri.localhost. The exchange endpoint at API_BASE_URL (https://api.gograb.space/api) will accept requests from the Tauri webview. No CORS changes needed.

Conventions Reminder