From 4f05041e054e75f9f99b8e8d5b73e925432abdde Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Wed, 28 Jan 2026 02:25:55 +0530 Subject: [PATCH] Fix deep link auth flow with Rust token exchange and platform workarounds Replace browser fetch() with Rust invoke() for token exchange to bypass CORS restrictions. Fix deep link listener by removing premature __TAURI__ guard (not available at module load), using lazy dynamic import instead. Add deepLinkHandled localStorage flag to prevent infinite reload loops from getCurrent() re-delivering the same URL after navigation. Add backend auth implementation guide and platform-specific deep link docs. Co-Authored-By: Claude Opus 4.5 --- .../rules/13-backend-auth-implementation.md | 248 +++++++++++++ .claude/rules/14-deep-link-platform-guide.md | 159 +++++++++ src-tauri/Cargo.lock | 336 +++++++++++++++++- src-tauri/Cargo.toml | 2 + src-tauri/src/lib.rs | 47 ++- src/main.tsx | 9 +- src/utils/config.ts | 2 +- src/utils/desktopDeepLinkListener.ts | 73 ++-- 8 files changed, 827 insertions(+), 49 deletions(-) create mode 100644 .claude/rules/13-backend-auth-implementation.md create mode 100644 .claude/rules/14-deep-link-platform-guide.md 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); - }); }; -