feat(server-auth): oauth-loopback browser handshake (Phase 1.f.desktop.1b)#190
Conversation
…p.1b) Replaces the manual-paste sign-in flow with a proper local-loopback OAuth-style handshake, mirroring the existing `commands::spotify` pattern. The companion `/desktop-login` route lives on [waveflow-web PR #18](InstaZDLL/waveflow-web#18). ## Backend - New `app_setting['app.waveflow_web_url']` for the waveflow-web URL (separate from the server URL — most deployments will host the API and the web on different domains). Same trim-and-validate gate as the server URL. - `ServerStatus` gains a `web_url` field. - New Tauri commands: - `server_set_web_url` - `server_begin_loopback_login` — generates a 256-bit `state` (anti-replay), spawns the `tiny_http` listener on `127.0.0.1:49388` in a blocking task with a 3-minute timeout, opens the browser to `<web-url>/desktop-login?cb=http://127.0.0.1:49388/wf/callback&state=…`, awaits the callback, validates `state`, persists the JWT via the existing `write_token` path, and returns the fresh `ServerStatus`. - `wait_for_callback` renders three confirmation pages depending on outcome (success / cancelled / state mismatch) so the user knows whether to close the tab or retry. - `random_state` reuses the same UUID-pair pattern Spotify uses for its PKCE verifier — 256 bits of entropy, URL-safe. ## Frontend - Settings → Intégrations → Compte serveur card now exposes: - Server URL field (existing) - **Web URL field** (new — required for the OAuth button) - **Sign in with browser** primary button (disabled until web URL set), with a `loginInProgress` state during the round-trip - Manual JWT paste fallback (kept for users who can't run the handshake — restricted networks, etc.) ## i18n - New keys `settings.serverAccount.{webUrlLabel, webUrlPlaceholder, signInWithBrowser, signInWithBrowserHint, loginInProgress}` propagated to all 17 locales per the CLAUDE.md convention. Brand tokens verbatim (WaveFlow, JWT, WaveFlow Web). ## Verified - `cargo check --workspace` - `cargo clippy --workspace --all-targets -- -D warnings` - `cargo fmt -p waveflow --check` - `bun run typecheck` - `bun run lint` Signed-off-by: InstaZDLL <github.105mh@8shield.net>
📝 WalkthroughWalkthroughCette PR implémente un flux d'authentification OAuth local "loopback" pour WaveFlow Desktop : l'application génère un state CSRF, lance un serveur HTTP éphémère, ouvre le navigateur vers WaveFlow Web avec callback local, reçoit le JWT, le persiste et met à jour l'état utilisateur. ChangesOAuth Loopback Login Flow
Sequence DiagramsequenceDiagram
participant User as Utilisateur (Desktop)
participant Desktop as App WaveFlow (Tauri)
participant LocalServer as Serveur HTTP éphémère
participant Browser as Navigateur
participant WebService as WaveFlow Web
User->>Desktop: Clique "Sign In"
Desktop->>Desktop: Génère state UUID
Desktop->>LocalServer: Lance listener (timeout)
Desktop->>Browser: Ouvre https://<web_url>/desktop-login?cb=http://127.0.0.1:<port>&state=xyz
Browser->>WebService: Authentifie l'utilisateur
WebService->>Browser: Redirige vers callback avec token & state
Browser->>LocalServer: GET callback
LocalServer->>LocalServer: Valide state CSRF
LocalServer->>Desktop: Persiste JWT via write_token
Desktop->>Browser: Répond page HTML de confirmation
Desktop->>User: Met à jour signed_in=true et ferme modal
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Suggested labels
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src-tauri/crates/app/src/commands/server_auth.rs`:
- Around line 196-218: The success match arm currently accepts (Some(token), _,
state) which allows a token even if parsed.error is also present; change the
pattern/guard so success only occurs when parsed.token is Some(_) AND
parsed.error is None AND parsed.state.as_deref() == Some(expected_state) (e.g.
match (parsed.token, parsed.error, parsed.state.as_deref()) with a guard or
explicit None for error), and add an explicit branch that treats the case where
both token and error are present as an error (respond via request.respond with
the denied/cancelled HTML and return Err(AppError::Other(...))) to enforce
defensive OAuth callback validation.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: 0a44a918-f123-4e5a-b480-d61d616be6eb
📒 Files selected for processing (22)
src-tauri/crates/app/src/commands/server_auth.rssrc-tauri/crates/app/src/lib.rssrc-tauri/crates/app/src/server_client.rssrc/components/views/settings/ServerAccountCard.tsxsrc/i18n/locales/ar.jsonsrc/i18n/locales/de.jsonsrc/i18n/locales/en.jsonsrc/i18n/locales/es.jsonsrc/i18n/locales/fr.jsonsrc/i18n/locales/hi.jsonsrc/i18n/locales/id.jsonsrc/i18n/locales/it.jsonsrc/i18n/locales/ja.jsonsrc/i18n/locales/ko.jsonsrc/i18n/locales/nl.jsonsrc/i18n/locales/pt-BR.jsonsrc/i18n/locales/pt.jsonsrc/i18n/locales/ru.jsonsrc/i18n/locales/tr.jsonsrc/i18n/locales/zh-CN.jsonsrc/i18n/locales/zh-TW.jsonsrc/lib/tauri/serverAuth.ts
@coderabbitai on PR #190 flagged that the success match arm accepted a token even when the callback also carried an `error` claim. Spec- wise the web side never sends both, but the explicit `None` guard makes the fall-through to the error arm authoritative — a future protocol change can't silently smuggle a token past the validation. Signed-off-by: InstaZDLL <github.105mh@8shield.net>
* feat(sync): per-profile sync mode toggle (Phase 1.f.desktop.3) Adds the user-facing "off-switch" the enqueue hooks #192 shipped need to be controllable. Per-profile sync mode persisted in profile_setting['sync.mode']; the existing sync::hooks gate now short-circuits when mode = Local even with a JWT configured. ## Backend (~250 LOC) - crate::sync::mode — SyncMode enum (Local, Hybrid), read/write helpers around profile_setting, 5 unit tests covering fresh-profile default, round-trip both directions, unknown-storage-value fallback, and const as_str/from_storage symmetry. - sync::hooks::enqueue_op gate: if no JWT skip; if SyncMode::Local skip; otherwise lamport::next + queue::enqueue. Fresh profile defaults to Hybrid so the post-sign-in flow Just Works. - New Tauri commands sync_get_mode and sync_set_mode (canonical string round-trip, rejects unknown modes with 400-style error). - sync_get_queue_state extended with a mode field so the Settings card renders both queue stats AND the active mode in one round-trip. ## Frontend (~120 LOC) - src/lib/tauri/serverAuth.ts wrapper: SyncMode type + syncGetMode + syncSetMode. - ServerAccountCard gains a radio under the JWT section, gated on signedIn && mode loaded so we don't flash an empty group during hydration. Promise.all([serverGetStatus, syncGetMode]) on initial load batches the two reads. - syncGetMode failure during initial hydration is caught and the radio simply stays hidden rather than blowing up the whole card (race against profile-switch). ## i18n - settings.serverAccount.{modeLabel, modes.hybrid.label, modes.hybrid.description, modes.local.label, modes.local.description} propagated to all 17 locales per the CLAUDE.md convention. ## Why no Server-connected mode? RFC-001 listed a third "thin-client" mode where reads come from HTTP instead of local SQLite. Deferred — waveflow-web already covers the thin-client use case, and routing desktop reads through HTTP forfeits the value the local audio engine + file scanner provide. The SyncMode enum is intentionally open-shaped so a future ServerOnly variant lands without touching the persistence + gate logic. ## Test plan - cargo test -p waveflow --lib sync::mode (5/5 green) - cargo clippy -p waveflow --all-targets -- -D warnings - cargo fmt -p waveflow --check - bun run typecheck - bun run lint - Manual smoke (requires #190 + waveflow-web #18 deployed): - Sign in to a profile → Settings → Compte serveur shows radio defaulted to "Hybrid" - Create a playlist → sync_get_queue_state shows pending_count: 1 - Flip to Local → pending_count stays 1, no new ops enqueue - Update playlist name → pending_count stays 1 (queue gated by mode) - Flip back to Hybrid → update playlist again → pending_count: 2 Signed-off-by: InstaZDLL <github.105mh@8shield.net> * fix(sync): update value_type on profile_setting upsert conflict @coderabbitai on PR #194 flagged that the mode::write UPSERT didn't refresh value_type on conflict. For sync.mode specifically value_type never drifts (mode::write is the only writer and always inserts 'string'), but the fix is four characters and closes the class of bugs where a hypothetical future writer puts a wrong type in the row that this UPSERT would then silently preserve. Cheap defence in depth — same shape every future UPSERT against profile_setting should adopt. Signed-off-by: InstaZDLL <github.105mh@8shield.net> --------- Signed-off-by: InstaZDLL <github.105mh@8shield.net>
Replaces the manual-paste sign-in flow from #189 with a proper local-loopback OAuth-style handshake, mirroring the existing `commands::spotify` pattern.
Cross-repo: this PR requires waveflow-web#18 to be merged before the OAuth button works end-to-end. The companion route adds `/desktop-login` on the web side that mints a JWT via `auth.api.getToken` and 302-redirects to the localhost callback.
Summary
Flow
```
/desktop-login?cb=http://127.0.0.1:49388/wf/callback&state=…
```
Security
Test plan
Follow-ups
Summary by CodeRabbit
Notes de Version
Nouvelles Fonctionnalités
Localisation