Skip to content

feat(server-auth): oauth-loopback browser handshake (Phase 1.f.desktop.1b)#190

Merged
InstaZDLL merged 2 commits into
mainfrom
feat/1-f-desktop-1b-oauth-loopback
May 31, 2026
Merged

feat(server-auth): oauth-loopback browser handshake (Phase 1.f.desktop.1b)#190
InstaZDLL merged 2 commits into
mainfrom
feat/1-f-desktop-1b-oauth-loopback

Conversation

@InstaZDLL
Copy link
Copy Markdown
Owner

@InstaZDLL InstaZDLL commented May 31, 2026

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

  • New `app_setting['app.waveflow_web_url']` — separate from the server URL so deployments that proxy API + web on different domains stay configurable.
  • `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 `tiny_http` on `127.0.0.1:49388` in a blocking task with 3-minute timeout, opens browser to `/desktop-login?cb=…&state=…`, awaits callback, validates `state`, persists JWT via existing `write_token`, returns fresh `ServerStatus`.
  • Settings card gains the Web URL field and a primary "Sign in with browser" button; manual paste fallback kept for restricted networks.
  • `settings.serverAccount.{webUrlLabel, webUrlPlaceholder, signInWithBrowser, signInWithBrowserHint, loginInProgress}` propagated to all 17 locales.

Flow

  1. User configures `Server URL` + `Web URL` in Settings.
  2. Clicks "Sign in with browser".
  3. Desktop generates random state, binds `127.0.0.1:49388`, opens browser to:
    ```
    /desktop-login?cb=http://127.0.0.1:49388/wf/callback&state=…
    ```
  4. Web validates session (or redirects through `/sign-in?continue=…`), mints JWT, 302-redirects to the localhost URL with `?token=…&state=…`.
  5. Desktop listener parses `?token` + `?state`, rejects mismatched state (possible CSRF), persists token, renders confirmation HTML in the tab.

Security

  • Random `state` is 256 bits (two concatenated UUIDv4s). Rejected on mismatch — handler returns an error string the UI surfaces verbatim.
  • Loopback callback is strictly `http://127.0.0.1:49388/wf/callback\` (different port from Spotify's 49387 so the two can coexist). Validated server-side by waveflow-web#18's `parseLoopback` (`http:` only, hostname ∈ {127.0.0.1, localhost, [::1]}, port 1024-65535).
  • 3-minute receive timeout. Cancelled / timeout / error all render a user-readable HTML response in the tab so the user knows what happened.
  • `spawn_blocking` for the listener so the Tauri runtime isn't pinned during the wait.

Test plan

  • `cargo check --workspace`
  • `cargo clippy --workspace --all-targets -- -D warnings`
  • `cargo fmt -p waveflow --check`
  • `bun run typecheck`
  • `bun run lint`
  • Manual smoke once waveflow-web#18 lands:

Follow-ups

  • 1.f.desktop.2: Lamport clock + pending-ops queue (consumes `WaveflowServerClient`).
  • 1.f.desktop.3: Settings "Mode serveur" toggle + runtime repo swap.
  • 1.f.desktop.4: WebSocket subscriber + apply remote ops.

Summary by CodeRabbit

Notes de Version

  • Nouvelles Fonctionnalités

    • Authentification via navigateur avec flux loopback pour une connexion plus fluide.
    • Nouveau champ de configuration "Web URL" pour pointer vers le frontend WaveFlow Web et bouton de sauvegarde dans les réglages.
    • Indicateur d'état de connexion mis à jour après tentative de login.
  • Localisation

    • Ajout de chaînes UI pour le flux de connexion par navigateur dans 18 langues.

…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>
@InstaZDLL InstaZDLL added scope: frontend React/Vite frontend (src/) scope: backend Rust/Tauri backend (src-tauri/) scope: i18n Translations (src/i18n/) labels May 31, 2026
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 31, 2026

Review Change Stack

📝 Walkthrough

Walkthrough

Cette 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.

Changes

OAuth Loopback Login Flow

Layer / File(s) Résumé
Données et persistance serveur
src-tauri/crates/app/src/server_client.rs
Extension de ServerStatus avec champ web_url optionnel, constante WEB_URL_KEY pour stockage, fonctions read_web_url et write_web_url avec validation http(s) et trim des valeurs vides.
Commande loopback OAuth et serveur HTTP
src-tauri/crates/app/src/commands/server_auth.rs
server_begin_loopback_login génère un state UUID, crée un listener HTTP one-shot avec timeout, ouvre le navigateur avec paramètres cb et state, parse la query string de callback, valide CSRF, persiste le JWT et répond avec page HTML. Nouvelles fonctions internes random_state et wait_for_callback.
Enregistrement Tauri et client TypeScript
src-tauri/crates/app/src/lib.rs, src/lib/tauri/serverAuth.ts
Ajout des handlers server_set_web_url et server_begin_loopback_login au registry, extension du type ServerStatus avec web_url, export de serverSetWebUrl et serverBeginLoopbackLogin pour invocation IPC.
Interface utilisateur et handlers
src/components/views/settings/ServerAccountCard.tsx
Ajout d'états webUrlDraft et loggingIn, handlers handleSaveWebUrl (persist avec trim) et handleOauthLogin (lance loopback), dérivation webUrlConfigured, UI pour saisir l'URL web et bouton "sign in" activé selon configuration et états.
Internationalization multilingue
src/i18n/locales/{ar,de,en,es,fr,hi,id,it,ja,ko,nl,pt-BR,pt,ru,tr,zh-CN,zh-TW}.json
5 nouvelles clés i18n harmonisées dans 17 fichiers de locale : webUrlLabel, webUrlPlaceholder, signInWithBrowser, signInWithBrowserHint, loginInProgress pour supporter l'UI d'authentification navigateur.

Sequence Diagram

sequenceDiagram
  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
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • InstaZDLL/WaveFlow#189: PR précédente qui introduit les bases de liaison de compte desktop (server_auth commands, ServerStatus initiale), que cette PR étend avec web_url et loopback OAuth.

Suggested labels

size: xl, scope: docs

Poem

🔐 State généré, serveur éphémère en veille,
le navigateur file, la page s'émerveille,
le token revient, signé et validé,
CSRF vérifié, le compte est sauvé. ✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed Le titre suit les conventions Conventional Commits avec un scope pertinent (server-auth) et décrit correctement la fonctionnalité principale : implémentation du handshake OAuth en local-loopback pour l'authentification.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed La description du PR suit le modèle requis avec un résumé clair, un flux documenté et un test plan détaillé.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/1-f-desktop-1b-oauth-loopback

Comment @coderabbitai help to get the list of available commands and usage tips.

@InstaZDLL InstaZDLL added type: feat New feature size: l 200-500 lines labels May 31, 2026
@InstaZDLL InstaZDLL self-assigned this May 31, 2026
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between f33a2ec and de82f7d.

📒 Files selected for processing (22)
  • src-tauri/crates/app/src/commands/server_auth.rs
  • src-tauri/crates/app/src/lib.rs
  • src-tauri/crates/app/src/server_client.rs
  • src/components/views/settings/ServerAccountCard.tsx
  • src/i18n/locales/ar.json
  • src/i18n/locales/de.json
  • src/i18n/locales/en.json
  • src/i18n/locales/es.json
  • src/i18n/locales/fr.json
  • src/i18n/locales/hi.json
  • src/i18n/locales/id.json
  • src/i18n/locales/it.json
  • src/i18n/locales/ja.json
  • src/i18n/locales/ko.json
  • src/i18n/locales/nl.json
  • src/i18n/locales/pt-BR.json
  • src/i18n/locales/pt.json
  • src/i18n/locales/ru.json
  • src/i18n/locales/tr.json
  • src/i18n/locales/zh-CN.json
  • src/i18n/locales/zh-TW.json
  • src/lib/tauri/serverAuth.ts

Comment thread src-tauri/crates/app/src/commands/server_auth.rs
@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>
@InstaZDLL InstaZDLL merged commit d2366c4 into main May 31, 2026
14 checks passed
@InstaZDLL InstaZDLL deleted the feat/1-f-desktop-1b-oauth-loopback branch May 31, 2026 14:49
InstaZDLL added a commit that referenced this pull request May 31, 2026
* 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

scope: backend Rust/Tauri backend (src-tauri/) scope: frontend React/Vite frontend (src/) scope: i18n Translations (src/i18n/) size: l 200-500 lines type: feat New feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant