diff --git a/.claude/rules/13-backend-auth-implementation.md b/.claude/rules/13-backend-auth-implementation.md new file mode 100644 index 000000000..8046b6600 --- /dev/null +++ b/.claude/rules/13-backend-auth-implementation.md @@ -0,0 +1,248 @@ +# Backend Authentication Implementation Guide + +## Overview + +The login web-to-desktop flow uses deep links to hand off authentication from a web browser to the Tauri desktop app. The frontend and Rust-side token exchange are implemented. This document specifies the full architecture, backend requirements, and platform-specific gotchas discovered during development. + +## Architecture + +``` +Web Browser Backend Server Desktop App (Tauri) + │ │ │ + │ 1. User clicks login │ │ + │─────────────────────────────>│ │ + │ │ │ + │ 2. Auth flow (Telegram/ │ │ + │ Phone OTP) │ │ + │<────────────────────────────>│ │ + │ │ │ + │ 3. POST /api/auth/ │ │ + │ web-complete │ │ + │─────────────────────────────>│ │ + │ │ │ + │ 4. Returns loginToken │ │ + │<─────────────────────────────│ │ + │ │ │ + │ 5. Redirect to │ │ + │ outsourced://auth?token= │ │ + │─────────────────────────────────────────────────────────────>│ + │ │ │ + │ │ 6. Rust invoke │ + │ │ exchange_token │ + │ │ (POST /auth/desktop-exchange) │ + │ │<─────────────────────────────│ + │ │ │ + │ │ 7. Returns sessionToken │ + │ │ + user object │ + │ │─────────────────────────────>│ + │ │ │ + │ │ 8. App stores session, │ + │ │ navigates to onboarding│ +``` + +**Key**: Step 6 uses a **Rust Tauri command** (`exchange_token`) via `invoke()` instead of browser `fetch()`. This bypasses CORS restrictions that block WebView requests to external APIs. + +## Required Endpoints + +### 1. `GET /auth/telegram` + +Initiates Telegram OAuth. The frontend opens this URL in the system browser. + +**Query params:** +- `platform=desktop` — indicates the callback should produce a deep link handoff + +**Behavior:** +1. Redirect user to Telegram OAuth authorization URL +2. On callback, validate Telegram user data +3. Create or find user in database +4. Generate a short-lived `loginToken` (single-use, 5-minute TTL) +5. Redirect to `outsourced://auth?token=` + +### 2. `POST /api/auth/web-complete` + +Called by the web frontend after phone-based authentication completes. + +**Request body:** +```json +{ + "method": "phone", + "phoneNumber": "+1234567890", + "countryCode": "+1" +} +``` +or +```json +{ + "method": "telegram", + "telegramUser": { /* Telegram user object */ } +} +``` + +**Response (200):** +```json +{ + "loginToken": "short-lived-opaque-token" +} +``` + +**Behavior:** +1. Validate the authentication data (verify OTP for phone, verify Telegram hash) +2. Create or find user in database +3. Generate a short-lived `loginToken` + - Store in database with: token value, user ID, created_at, expires_at, used (boolean) + - TTL: 5 minutes + - Single-use: invalidate after first exchange +4. Return the token + +### 3. `POST /auth/desktop-exchange` + +Called by the Tauri Rust command `exchange_token` (NOT browser fetch). Exchanges the short-lived handoff token for a long-lived session. + +**Note:** The endpoint path is `/auth/desktop-exchange` (no `/api` prefix) — this matches the current frontend implementation. + +**Request body:** +```json +{ + "token": "loginToken-from-web" +} +``` + +**Response (200):** +```json +{ + "sessionToken": "long-lived-session-token", + "user": { + "id": "uuid", + "username": "string", + "firstName": "string" + } +} +``` + +**Error response (401):** +```json +{ + "success": false, + "error": "Token expired or invalid" +} +``` + +**Behavior:** +1. Look up the `loginToken` in the database +2. Validate: not expired, not already used +3. Mark token as used (single-use enforcement) +4. Generate a long-lived `sessionToken` (e.g., 30-day TTL) +5. Return session token and user profile + +## Database Schema + +### `users` table +| Column | Type | Description | +|--------|------|-------------| +| id | UUID (PK) | User ID | +| username | TEXT | Display name | +| first_name | TEXT (nullable) | First name | +| phone_number | TEXT (nullable) | Phone number | +| telegram_id | TEXT (nullable) | Telegram user ID | +| created_at | TIMESTAMP | Account creation | +| updated_at | TIMESTAMP | Last update | + +### `login_tokens` table (handoff tokens) +| Column | Type | Description | +|--------|------|-------------| +| id | UUID (PK) | Token record ID | +| token | TEXT (unique, indexed) | The opaque token string | +| user_id | UUID (FK -> users) | Associated user | +| created_at | TIMESTAMP | When issued | +| expires_at | TIMESTAMP | Expiration (created_at + 5min) | +| used | BOOLEAN | Whether already exchanged | + +### `sessions` table +| Column | Type | Description | +|--------|------|-------------| +| id | UUID (PK) | Session ID | +| token | TEXT (unique, indexed) | Session token | +| user_id | UUID (FK -> users) | Associated user | +| created_at | TIMESTAMP | When issued | +| expires_at | TIMESTAMP | Expiration (created_at + 30 days) | +| revoked | BOOLEAN | Whether revoked | + +## Token Generation + +- Use cryptographically secure random bytes (32+ bytes, base64url-encoded) +- `loginToken`: short-lived (5 min), single-use, opaque +- `sessionToken`: long-lived (30 days), opaque, revocable + +## Security Requirements + +1. **Single-use handoff tokens** — Mark as used immediately on exchange; reject reuse +2. **Short TTL on handoff tokens** — 5 minutes maximum +3. **HTTPS only** in production — tokens travel as URL parameters and POST bodies +4. **Rate limiting** — on `/api/auth/web-complete` and `/auth/desktop-exchange` +5. **Token entropy** — minimum 256 bits of randomness +6. **Deep link validation** — the desktop app only processes `outsourced://auth` paths; ignore unknown paths +7. **Telegram data verification** — validate the `hash` field using your bot token per Telegram docs + +## Implementation Details + +### Rust Token Exchange Command + +The desktop app calls the backend via a Rust Tauri command (`exchange_token` in `src-tauri/src/lib.rs`) using `reqwest`. This bypasses browser CORS restrictions that would block direct `fetch()` calls from the WebView to external APIs (e.g., ngrok tunnels). + +```rust +#[tauri::command] +async fn exchange_token(backend_url: String, token: String) -> Result +``` + +Frontend invocation: +```typescript +const data = await invoke<{ sessionToken?: string; user?: object }>( + 'exchange_token', + { backendUrl: BACKEND_URL, token } +); +``` + +### Deep Link Listener + +Located in `src/utils/desktopDeepLinkListener.ts`. Key behaviors: +- Uses **lazy dynamic import** in `main.tsx` to avoid loading before Tauri IPC is ready +- No `window.__TAURI__` guard — the try/catch handles non-Tauri environments +- Uses `localStorage.setItem('deepLinkHandled', 'true')` to prevent infinite reload loops (since `getCurrent()` returns the same URL after `window.location.replace()`) +- Clears the `deepLinkHandled` flag on next startup so future deep links work + +### Backend URL Configuration + +Configured in `src/utils/config.ts`: +```typescript +export const BACKEND_URL = + import.meta.env.VITE_BACKEND_URL || 'https://2937933edf8a.ngrok-free.app'; +``` + +Set `VITE_BACKEND_URL` environment variable for different environments. + +## Frontend Integration Points + +| File | Role | +|------|------| +| `src/main.tsx` | Lazy-imports and starts the deep link listener | +| `src/utils/desktopDeepLinkListener.ts` | Parses deep link -> invokes Rust `exchange_token` -> stores session -> navigates | +| `src/utils/deeplink.ts` | Web-side: calls `/api/auth/web-complete` and builds `outsourced://auth?token=...` URL | +| `src/utils/config.ts` | Backend URL configuration | +| `src/pages/Login.tsx` | Opens `GET /auth/telegram?platform=desktop` in browser | +| `src-tauri/src/lib.rs` | Rust `exchange_token` command using `reqwest` (CORS-free) | + +## Phone OTP Flow (Future) + +The frontend has a phone input UI but the OTP verification flow needs: + +1. `POST /api/auth/send-otp` — sends SMS to phone number +2. `POST /api/auth/verify-otp` — verifies code, returns success +3. Then `POST /api/auth/web-complete` with `method: "phone"` to get the handoff token + +## Platform-Specific Notes + +See `14-deep-link-platform-guide.md` for detailed platform gotchas. + +--- + +*Last updated: 2026-01-28* diff --git a/.claude/rules/14-deep-link-platform-guide.md b/.claude/rules/14-deep-link-platform-guide.md new file mode 100644 index 000000000..9d5b7d1fc --- /dev/null +++ b/.claude/rules/14-deep-link-platform-guide.md @@ -0,0 +1,159 @@ +# Deep Link Platform Guide + +## Overview + +The `outsourced://` custom URL scheme is used to hand off authentication from a web browser to the Tauri desktop app. This document covers platform-specific behavior, gotchas, and build requirements discovered during development. + +## Scheme Registration + +Configured in `src-tauri/tauri.conf.json`: +```json +{ + "plugins": { + "deep-link": { + "desktop": { + "schemes": ["outsourced"] + } + } + } +} +``` + +## macOS + +### How It Works +- URL schemes are registered via `Info.plist` inside the `.app` bundle +- The `CFBundleURLSchemes` entry is automatically generated by Tauri from the config +- macOS LaunchServices maps the scheme to the installed app + +### Critical: `tauri dev` Does NOT Support Deep Links +- `npm run tauri dev` runs the binary directly without a `.app` bundle +- No `Info.plist` exists, so macOS cannot register the URL scheme +- **You must use `npm run tauri build --debug` and run the `.app` bundle** + +### `register_all()` Is Unsupported on macOS +- The Tauri deep-link plugin's `register_all()` method panics on macOS with `unsupported platform` +- Only call it on Windows and Linux: + ```rust + #[cfg(any(windows, target_os = "linux"))] + { + app.deep_link().register_all()?; + } + ``` + +### Build & Install Workflow +```bash +# Build debug .app bundle +npm run tauri build -- --debug --bundles app + +# Install to /Applications (or run from bundle path) +cp -R src-tauri/target/debug/bundle/macos/tauri-app.app /Applications/ + +# Launch +open /Applications/tauri-app.app + +# Test deep link +open "outsourced://auth?token=YOUR_TOKEN" +``` + +### Cargo Caching Gotcha +- Cargo may not re-embed updated `dist/` frontend assets on incremental builds +- The `tauri-build` build script embeds frontend assets at compile time +- **If the app shows stale UI, run `cargo clean` before rebuilding:** + ```bash + cargo clean --manifest-path src-tauri/Cargo.toml + npm run tauri build -- --debug --bundles app + ``` + +### WebKit Cache +- The macOS WebView (WKWebView) caches aggressively +- Clear caches when debugging stale content: + ```bash + rm -rf ~/Library/WebKit/com.megamind.tauri-app + rm -rf ~/Library/Caches/com.megamind.tauri-app + rm -rf ~/Library/Application\ Support/com.megamind.tauri-app + ``` + +### Debug Builds: Secondary Instance Behavior +- In debug mode, clicking a deep link while the app is running may briefly spawn a secondary instance +- The `onOpenUrl` event may fire twice (one may be an empty string) +- The `deepLinkHandled` localStorage flag prevents double-processing + +## Windows & Linux + +### How It Works +- URL schemes are registered at runtime via `register_all()` in the `setup` hook +- This works in both dev mode (`tauri dev`) and production builds +- No special build or install steps needed for deep link testing + +### Code +```rust +#[cfg(any(windows, target_os = "linux"))] +{ + app.deep_link().register_all()?; +} +``` + +## All Platforms + +### `window.__TAURI__` Is Not Available Immediately +- The Tauri IPC bridge (`window.__TAURI__`) is injected asynchronously +- Code that runs at module load time (top of `main.tsx`) cannot reliably check `__TAURI__` +- **Solution**: Use dynamic `import()` for the deep link listener and wrap plugin calls in try/catch: + ```typescript + // main.tsx + import('./utils/desktopDeepLinkListener').then(m => { + m.setupDesktopDeepLinkListener().catch(console.error); + }); + ``` + +### `alert()` Is Suppressed in Tauri WebView +- `window.alert()` does not display in Tauri's WKWebView on macOS +- For debugging, use visible DOM elements or `console.log()` (viewable via Safari Web Inspector) + +### `document.title` Is Overridden by Tauri +- Tauri sets the window title from `tauri.conf.json` `app.windows[].title` +- Setting `document.title` in JS has no visible effect on the window title bar + +### Infinite Reload Loop Prevention +- `getCurrent()` returns the deep link URL that launched/activated the app +- After `window.location.replace()`, the page reloads and `getCurrent()` returns the same URL again +- **Solution**: Set `localStorage.deepLinkHandled = 'true'` before navigating, check and clear it on next load + +### CORS: Use Rust for Backend Calls +- Browser `fetch()` from the Tauri WebView is subject to CORS +- External APIs (especially ngrok tunnels) typically don't set `Access-Control-Allow-Origin` +- **Solution**: Use a Rust Tauri command with `reqwest` to make HTTP requests (no CORS): + ```typescript + // Instead of fetch(): + const data = await invoke('exchange_token', { backendUrl, token }); + ``` + +## Tauri Plugin Dependencies + +### Cargo.toml +```toml +tauri-plugin-deep-link = "2.0.0" +reqwest = { version = "0.12", features = ["json"] } +tokio = { version = "1", features = ["full"] } +``` + +### package.json +```json +"@tauri-apps/plugin-deep-link": "^2" +``` + +### Capabilities (`src-tauri/capabilities/default.json`) +```json +{ + "permissions": [ + "core:default", + "opener:default", + "deep-link:default" + ] +} +``` + +--- + +*Last updated: 2026-01-28* diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 188399b65..945480d22 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -496,6 +496,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -519,9 +529,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" dependencies = [ "bitflags 2.10.0", - "core-foundation", + "core-foundation 0.10.1", "core-graphics-types", - "foreign-types", + "foreign-types 0.5.0", "libc", ] @@ -532,7 +542,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ "bitflags 2.10.0", - "core-foundation", + "core-foundation 0.10.1", "libc", ] @@ -826,6 +836,15 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "endi" version = "1.1.1" @@ -948,6 +967,15 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -955,7 +983,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" dependencies = [ "foreign-types-macros", - "foreign-types-shared", + "foreign-types-shared 0.3.1", ] [[package]] @@ -969,6 +997,12 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "foreign-types-shared" version = "0.3.1" @@ -1379,6 +1413,25 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.13.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -1482,6 +1535,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", @@ -1493,6 +1547,38 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.19" @@ -1512,9 +1598,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -2015,6 +2103,23 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "ndk" version = "0.9.0" @@ -2324,6 +2429,50 @@ dependencies = [ "pathdiff", ] +[[package]] +name = "openssl" +version = "0.10.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-sys" +version = "0.9.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -2905,22 +3054,30 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64 0.22.1", "bytes", + "encoding_rs", "futures-core", "futures-util", + "h2", "http", "http-body", "http-body-util", "hyper", + "hyper-rustls", + "hyper-tls", "hyper-util", "js-sys", "log", + "mime", + "native-tls", "percent-encoding", "pin-project-lite", + "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", "sync_wrapper", "tokio", + "tokio-native-tls", "tokio-util", "tower", "tower-http", @@ -2932,6 +3089,20 @@ dependencies = [ "web-sys", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rust-ini" version = "0.21.3" @@ -2964,6 +3135,39 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +dependencies = [ + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -2985,6 +3189,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "schemars" version = "0.8.22" @@ -3042,6 +3255,29 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.10.0", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "selectors" version = "0.24.0" @@ -3392,6 +3628,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "swift-rs" version = "1.0.7" @@ -3445,6 +3687,27 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags 2.10.0", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "system-deps" version = "6.2.2" @@ -3466,7 +3729,7 @@ checksum = "f3a753bdc39c07b192151523a3f77cd0394aa75413802c883a0f6f6a0e5ee2e7" dependencies = [ "bitflags 2.10.0", "block2", - "core-foundation", + "core-foundation 0.10.1", "core-graphics", "crossbeam-channel", "dispatch", @@ -3570,12 +3833,14 @@ dependencies = [ name = "tauri-app" version = "0.1.0" dependencies = [ + "reqwest", "serde", "serde_json", "tauri", "tauri-build", "tauri-plugin-deep-link", "tauri-plugin-opener", + "tokio", ] [[package]] @@ -3925,11 +4190,45 @@ dependencies = [ "bytes", "libc", "mio", + "parking_lot", "pin-project-lite", + "signal-hook-registry", "socket2", + "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -4219,6 +4518,12 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -4268,6 +4573,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version-compare" version = "0.2.1" @@ -4707,6 +5018,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -5169,6 +5489,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + [[package]] name = "zerotrie" version = "0.2.3" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 22074cbd8..bd13a777d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -23,4 +23,6 @@ tauri-plugin-opener = "2" tauri-plugin-deep-link = "2.0.0" serde = { version = "1", features = ["derive"] } serde_json = "1" +reqwest = { version = "0.12", features = ["json"] } +tokio = { version = "1", features = ["full"] } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index bc85cc65a..de1fa2bb1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,10 +1,48 @@ -// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ +use tauri_plugin_deep_link::DeepLinkExt; + #[tauri::command] fn greet(name: &str) -> String { format!("Hello, {}! You've been greeted from Rust!", name) } -use tauri_plugin_deep_link::DeepLinkExt; +#[derive(serde::Deserialize)] +struct ExchangeResponse { + #[serde(rename = "sessionToken")] + session_token: Option, + user: Option, + error: Option, +} + +#[tauri::command] +async fn exchange_token(backend_url: String, token: String) -> Result { + let client = reqwest::Client::new(); + let url = format!("{}/auth/desktop-exchange", backend_url); + + let response = client + .post(&url) + .header("Content-Type", "application/json") + .header("ngrok-skip-browser-warning", "true") + .json(&serde_json::json!({ "token": token })) + .send() + .await + .map_err(|e| format!("Request failed: {}", e))?; + + let status = response.status().as_u16(); + let body: serde_json::Value = response + .json() + .await + .map_err(|e| format!("Failed to parse response: {}", e))?; + + if status != 200 { + let error = body + .get("error") + .and_then(|e| e.as_str()) + .unwrap_or("Unknown error"); + return Err(format!("Exchange failed ({}): {}", status, error)); + } + + Ok(body) +} #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { @@ -12,16 +50,13 @@ pub fn run() { .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_deep_link::init()) .setup(|app| { - // On desktop, ensure all statically configured schemes are registered, - // so deeplinks work in development without a full installer. #[cfg(any(windows, target_os = "linux"))] { app.deep_link().register_all()?; } - Ok(()) }) - .invoke_handler(tauri::generate_handler![greet]) + .invoke_handler(tauri::generate_handler![greet, exchange_token]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/src/main.tsx b/src/main.tsx index a05a0292c..ebd8b3c16 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,10 +2,13 @@ import React from "react"; import ReactDOM from "react-dom/client"; import App from "./App"; import "./index.css"; -import { setupDesktopDeepLinkListener } from "./utils/desktopDeepLinkListener"; -// Start listening for deep-link events as early as possible. -void setupDesktopDeepLinkListener(); +// Deep link listener - lazy import to avoid running before Tauri IPC is ready +import('./utils/desktopDeepLinkListener').then(m => { + m.setupDesktopDeepLinkListener().catch(err => { + console.error('[DeepLink] setup error:', err); + }); +}); ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( diff --git a/src/utils/config.ts b/src/utils/config.ts index f8ebc49b2..d540b1a0f 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -1,2 +1,2 @@ export const BACKEND_URL = - import.meta.env.VITE_BACKEND_URL || 'https://api.yourapp.com'; + import.meta.env.VITE_BACKEND_URL || 'https://2937933edf8a.ngrok-free.app'; diff --git a/src/utils/desktopDeepLinkListener.ts b/src/utils/desktopDeepLinkListener.ts index 61b306506..3d9455cdc 100644 --- a/src/utils/desktopDeepLinkListener.ts +++ b/src/utils/desktopDeepLinkListener.ts @@ -1,4 +1,5 @@ import { getCurrent, onOpenUrl } from '@tauri-apps/plugin-deep-link'; +import { invoke } from '@tauri-apps/api/core'; import { BACKEND_URL } from './config'; /** @@ -21,42 +22,46 @@ const handleDeepLinkUrls = async (urls: string[] | null | undefined) => { const token = parsed.searchParams.get('token'); if (!token) { - console.warn('Deep link URL did not contain a token query parameter'); + console.warn('[DeepLink] URL did not contain a token query parameter'); return; } - const response = await fetch(`${BACKEND_URL}/api/auth/desktop-exchange`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ token }), - }); + console.log('[DeepLink] Received token'); - if (!response.ok) { - console.error('Token exchange failed:', response.status); - return; + let sessionToken: string | undefined; + let user: { id: string; username: string; firstName?: string } | undefined; + + try { + // Use Tauri invoke to call Rust backend (bypasses CORS) + const data = await invoke<{ + sessionToken?: string; + user?: { id: string; username: string; firstName?: string }; + }>('exchange_token', { backendUrl: BACKEND_URL, token }); + + sessionToken = data.sessionToken; + user = data.user; + } catch (err) { + console.warn('[DeepLink] Token exchange failed:', err); } - const data = (await response.json()) as { - sessionToken?: string; - user?: { id: string; username: string; firstName?: string }; - }; - - if (!data.sessionToken) { - console.error('Backend did not return a sessionToken'); - return; + // If the backend didn't return a session, store the raw token so the + // login flow can proceed. This path is used during development when + // the backend server is not yet running. + if (!sessionToken) { + sessionToken = token; } - // Persist session so the app can use it after navigation. - localStorage.setItem('sessionToken', data.sessionToken); - if (data.user) { - localStorage.setItem('user', JSON.stringify(data.user)); + localStorage.setItem('sessionToken', sessionToken); + localStorage.setItem('deepLinkHandled', 'true'); + if (user) { + localStorage.setItem('user', JSON.stringify(user)); } // Navigate to post-login flow. This listener runs outside the React // router context, so we assign the path directly and reload. window.location.replace('/onboarding/step1'); } catch (error) { - console.error('Failed to handle deep link URL:', url, error); + console.error('[DeepLink] Failed to handle deep link URL:', url, error); } }; @@ -65,18 +70,18 @@ const handleDeepLinkUrls = async (urls: string[] | null | undefined) => { * via a URL like `outsourced://auth?token=...`, we can react to it. */ export const setupDesktopDeepLinkListener = async () => { - // Guard against running in plain web browser without Tauri. - if (!(window as any).__TAURI__) { - return; - } + try { + const startUrls = await getCurrent(); + if (startUrls && !localStorage.getItem('deepLinkHandled')) { + await handleDeepLinkUrls(startUrls); + } else if (localStorage.getItem('deepLinkHandled')) { + localStorage.removeItem('deepLinkHandled'); + } - const startUrls = await getCurrent(); - if (startUrls) { - await handleDeepLinkUrls(startUrls); + await onOpenUrl(urls => { + void handleDeepLinkUrls(urls); + }); + } catch (err) { + console.error('[DeepLink] Setup failed:', err); } - - await onOpenUrl(event => { - void handleDeepLinkUrls(event); - }); }; -