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:
- Browser blocks custom scheme navigation without a user gesture (Chrome, Safari)
- macOS Gatekeeper may block unsigned/untrusted apps from receiving URL scheme events
Info.plistmisconfiguration — ifCFBundleURLTypesis missing or not merged during build, the scheme isn't registered- App not in
/Applications— Tauri deep links only work on bundled, installed apps (nottauri dev) - 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
- No custom URL scheme needed — polling over HTTPS is universally reliable
- No browser permission prompts — standard HTTP, not custom scheme navigation
- Works in
tauri dev— unlike deep links which require a bundled app - One-time read + TTL — the token exchange entry is consumed on first read and expires after 3 minutes regardless, minimizing attack surface
- Deep link remains as enhancement — if it works, great (instant), if not, polling catches it within 2s
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:
sidmust be a valid UUID v4 (reject malformed)- Rate limit: max 60 requests/minute per IP on GET (polling)
- No authentication required on GET (the sid IS the secret — UUID v4 has 122 bits of entropy)
- Token is encrypted at rest in the Map? No — overkill for 3-minute TTL in memory. The sid provides sufficient security.
- CORS:
tauri://localhostis already allowed (verified incors.factory.ts→isTauriOrigin())
In-memory store vs Redis:
- For MVP: use a simple
Map<string, { token: string; user: string; expiresAt: number }>with a cleanup interval - If we move to multi-instance backend: migrate to Redis with
SETEX(trivial change) - Current architecture: single Railway instance per backend, so in-memory is fine
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:
pollForToken()uses RxJStimer(0, 2000)+switchMapto poll every 2s- Stops on: success (token received), timeout (120s), or component destruction
- Uses
API_BASE_URL(@libs-foundational/api-config) — same as AuthService - No Promises — RxJS only per project conventions
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
- User closes browser before completing login — polling times out after 120s, shows error. Already handled by
AUTH_TIMEOUT_MS. - User completes login but backend is down — POST fails, but deep link may still work. Show error only if both fail.
- Multiple signIn() attempts — each generates a new
sid. Old sids expire in 3 minutes. No conflict. - Race condition: deep link AND poll both return —
take(1)ensures only the first is processed.restoreFromDeepLinkPayloadis idempotent (overwrites localStorage). - 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
- Don't use WebSockets for the exchange — polling at 2s intervals is simpler, more reliable, and sufficient for this use case
- Don't add Redis for MVP — in-memory Map with TTL is adequate for single-instance
- Don't remove the deep link path — keep it as a parallel fast-path. When it works, it's instant (vs 2s polling delay)
- Don't add a local HTTP server in Tauri — VS Code does this but it adds complexity (port conflicts, firewall rules). Polling is simpler.
Testing Plan
- Build Tauri app (
tauri build --debug), install in/Applications - Test GoGrab tab → browser opens → login → app receives token via polling
- Test with deep link ALSO working (verify race resolution)
- 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 attempts: click "Sign in" twice rapidly → verify no double-login
- 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
- Angular 20+, standalone, OnPush,
inject() - RxJS Observables ONLY — no Promises, no setTimeout (except in Tauri bridge which is async by nature)
- Types in
models/directory - Use
API_BASE_URLfrom@libs-foundational/api-configfor the exchange endpoint - Commits:
[S290.N] type: description git add <specific-files>— nevergit add .