Skip to content

feat(desktop-login): /desktop-login route for desktop OAuth handshake#18

Merged
InstaZDLL merged 3 commits into
mainfrom
feat/1-f-1b-desktop-login
May 31, 2026
Merged

feat(desktop-login): /desktop-login route for desktop OAuth handshake#18
InstaZDLL merged 3 commits into
mainfrom
feat/1-f-1b-desktop-login

Conversation

@InstaZDLL
Copy link
Copy Markdown
Owner

@InstaZDLL InstaZDLL commented May 31, 2026

Companion web-side PR for Phase 1.f.desktop.1b on the WaveFlow desktop repo — adds the route the desktop's planned loopback listener (mirroring `commands::spotify`) hits to swap a Better Auth session for a fresh waveflow-server JWT.

Summary

  • New route `/desktop-login`. Search params: `cb` (loopback URL) + `state` (anti-replay token).
  • New server fn `resolveDesktopLogin` (`src/server-fns/desktop-login.ts`) handles the validation + minting server-side (route files get bundled into the client, so importing `@tanstack/react-start/server` directly into the route would fail the import-protection plugin at build time).
  • Sign-in route gains an optional `continue` search param + a `safeContinueTarget` whitelist that restricts post-login navigate to `/desktop-login` — keeps a crafted link from turning the route into an open-redirect.

Flow

  1. Desktop binds `127.0.0.1:PORT/cb`, generates a random `state`, opens the browser to:
    ```
    /desktop-login?cb=http://127.0.0.1:49388/cb&state=…
    ```
  2. `resolveDesktopLogin` validates `cb` + `state` and probes the Better Auth session:
    • No session → route's `beforeLoad` throws `redirect({ to: '/sign-in', search: { continue: '/desktop-login?…' } })`. Sign-in resumes the flow after the user enters credentials.
    • Session active → mints JWT via `auth.api.getToken`, returns `{ kind: 'redirect', url: '?token=…&state=…' }`. Route throws `redirect({ href })` for a 302.
    • Invalid `cb` / `state` → renders an error page; no DOM rendering of the token.
    • Mint failure (session existed, getToken refused) → error page.

Security: `parseLoopback`

Every JWT leak vector goes through this validator. Rules:

  • `protocol === 'http:'` (loopback doesn't get TLS, the desktop listener is a plain `tiny_http` socket)
  • `hostname ∈ { '127.0.0.1', 'localhost', '[::1]' }` — case-insensitive
  • `port ∈ [1024, 65535]` (non-privileged)

Test cases in `desktop-login.test.ts` cover every rejection branch — privileged ports, port 0, port > 65535, missing port, public IPs, link-local addresses (169.254.169.254 cloud metadata), `https://`, `file://`, `javascript:`, unicode-confusing hostnames like `127.0.0.1.attacker.com`. 13 cases total, all green.

Test plan

  • `bun run test` clean (30 tests passing)
  • `bun run typecheck` clean
  • `bun run lint` clean
  • `bun run build` clean (verified before push)
  • Manual smoke once the desktop side ships:

Summary by CodeRabbit

  • New Features

    • Nouveau point d’entrée desktop pour l’authentification et émission sécurisée d’un jeton, avec redirections appropriées.
    • Conservation et validation stricte de la destination "continue" après authentification.
  • Bug Fixes / UX

    • Écrans utilisateur clairs pour "lien de connexion invalide" et "impossibilité d’émettre le token".
    • Redirection vers la page de connexion quand la session est absente, en préservant la destination.
  • Tests

    • Suites de tests ajoutées pour la validation des URLs de callback et de la logique "continue".

Phase 1.f.desktop.1b — bridges the WaveFlow desktop's planned
tiny_http loopback listener (mirroring the existing
`commands::spotify` pattern) to Better Auth's JWT minting:

1. Desktop binds 127.0.0.1:PORT/cb, generates a random `state`, opens
   the browser to `/desktop-login?cb=…&state=…`.
2. `resolveDesktopLogin` server fn validates `cb` (loopback only),
   checks for a Better Auth session, and mints a fresh JWT via
   `auth.api.getToken`.
3. Browser is server-side-redirected (302) to `<cb>?token=…&state=…`.
   Desktop listener validates `state` and stores the JWT.

Security boundary lives in `parseLoopback`:
- `protocol === 'http:'` (loopback doesn't get TLS, the desktop
  listener is plain).
- `hostname ∈ { '127.0.0.1', 'localhost', '[::1]' }` — no external
  hosts. A malicious link with `cb=http://attacker.com:49388/cb`
  would let a phishing site exfiltrate the JWT, so the validator
  rejects everything that isn't unambiguously loopback.
- `port ∈ [1024, 65535]` — non-privileged.
- Case-insensitive hostname comparison.

Sign-in route gains a `continue` search param so the no-session path
can resume the OAuth flow after login. `safeContinueTarget`
whitelists `/desktop-login` to keep it from becoming an
open-redirect.

Live tests: 13 cases on `parseLoopback` covering every accept +
reject branch (privileged ports, IP-confusing unicode hostnames,
non-http schemes, external hosts, etc.).

Signed-off-by: InstaZDLL <github.105mh@8shield.net>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 31, 2026

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 926665cb-b141-46c6-ac8a-9cb712172e23

📥 Commits

Reviewing files that changed from the base of the PR and between 6834184 and 63ddfea.

📒 Files selected for processing (2)
  • src/routes/sign-in.test.ts
  • src/routes/sign-in.tsx

📝 Walkthrough

Walkthrough

Implémentation d’un pont OAuth-like desktop : parseLoopback sécurisé + tests, server-fn resolveDesktopLogin (session→token→redirect/state mapping), route client /desktop-login avec beforeLoad pour redirections/états, et sécurisation du paramètre continue dans /sign-in avec tests.

Changes

Desktop Login Flow

Layer / File(s) Summary
Backend contract & URL security validation
src/server-fns/desktop-login.ts, src/server-fns/desktop-login.test.ts
Ajout des types DesktopLoginInput/DesktopLoginResolution et parseLoopback(raw) qui n'accepte que http: + hôtes loopback (127.0.0.1, localhost, ::1) et ports 1024–65535. Suite de tests Vitest couvrant acceptations, rejets et insensibilité à la casse.
Server orchestration: resolveDesktopLogin
src/server-fns/desktop-login.ts
Server function GET qui valide l'input, appelle parseLoopback, récupère session (auth.api.getSession), tente auth.api.getToken, mappe UNAUTHORIZEDno-session, autres erreurs → mint-failed, ou renvoie redirect avec token+state.
Desktop login route & component
src/routes/desktop-login.tsx
Route /desktop-login avec validateSearch(cb,state) et beforeLoad appelant resolveDesktopLogin; gère redirect (throw redirect(href)), no-session (redirige vers /sign-in?continue=...) ou retourne { status } pour rendre les écrans d'erreur (invalid-callback, mint-failed).
Sign-in continuation safety
src/routes/sign-in.tsx, src/routes/sign-in.test.ts
Ajout de safeContinueTarget(raw) et intégration de continue via validateSearch. safeContinueTarget restreint les cibles au préfixe /desktop-login (sinon /), préserve pathname+search sans hash. Navigation post-auth utilise continueTo. Tests couvrant open-redirect et cas valides.

Sequence Diagram

sequenceDiagram
  participant User
  participant Browser
  participant Route as /desktop-login beforeLoad
  participant ServerFn as resolveDesktopLogin
  participant Auth as auth.api

  User->>Browser: ouvre /desktop-login?cb=...&state=...
  Browser->>Route: beforeLoad
  Route->>ServerFn: resolveDesktopLogin(cb,state)
  ServerFn->>Auth: getSession()
  Auth-->>ServerFn: session / APIError(UNAUTHORIZED)
  alt session ok
    ServerFn->>Auth: getToken()
    Auth-->>ServerFn: token / APIError(UNAUTHORIZED) / error
    alt token ok
      ServerFn-->>Route: { status: 'redirect', href }
      Route-->>Browser: throw redirect(href)
    else token fail
      ServerFn-->>Route: { status: 'mint-failed' }
      Route->>Browser: render error UI
    end
  else no session
    ServerFn-->>Route: { status: 'no-session' }
    Route-->>Browser: throw redirect('/sign-in?continue=...') 
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • InstaZDLL/waveflow-web#14: Modifie src/routes/sign-in.tsx — création UI sign-in et base pour l'ajout ultérieur du paramètre continue et de la logique de navigation restreinte.

Poem

Un callback en boucle, précis et clair,
Le serveur cherche la session derrière,
Si perdue, sign-in reprend la route,
Sinon le token file sans doute,
Pont desktop tissé, sûr et fier.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 40.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed Le titre décrit avec précision la principale modification : ajout d'une route /desktop-login pour le handshake OAuth côté desktop.
Description check ✅ Passed La description couvre tous les éléments essentiels : Summary détaillé, Changes, Flow complet, sécurité parseLoopback, et Test plan avec cases validées.
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.

✏️ 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-1b-desktop-login

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

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/routes/sign-in.tsx`:
- Around line 20-24: La fonction safeContinueTarget accepte des entrées comme
"/desktop-login/../admin" qui passent le startsWith mais redirigent hors de la
zone voulue ; update safeContinueTarget to first parse/normalize the incoming
raw value into a canonical pathname (e.g., with URL or path.posix.normalize) and
then validate that the normalized pathname startsWith "/desktop-login" and does
not contain ".." or resolve outside the allowed prefix (also reject absolute
URLs or host components); return "/" for any invalid or unsafe result. Ensure
you modify the safeContinueTarget implementation to perform normalization before
the startsWith check and keep the same return behavior.
🪄 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: 6aafcedb-76dd-4432-beb5-0370c2bd89da

📥 Commits

Reviewing files that changed from the base of the PR and between fe9aaf7 and c8b5733.

📒 Files selected for processing (4)
  • src/routes/desktop-login.tsx
  • src/routes/sign-in.tsx
  • src/server-fns/desktop-login.test.ts
  • src/server-fns/desktop-login.ts

Comment thread src/routes/sign-in.tsx Outdated
@coderabbitai on PR #18 surfaced two findings:

1. `safeContinueTarget` accepted `/desktop-login/../admin` and similar
   path-traversal payloads because `startsWith` ran on the raw input.
   The browser would normalise the URL after navigation and land the
   user on `/admin`, defeating the prefix gate. Fix: parse against a
   dummy base, reject any value whose normalised `origin` isn't the
   base (catches absolute / protocol-relative URLs), then check the
   normalised pathname. Adds 18 test cases covering path traversal,
   trailing-slash edge cases, host-injection variants
   (`https://attacker.com`, `//attacker.com`, `javascript:`,
   `data:` schemes), and the legitimate happy path.

2. `bun run format:check` failed on `desktop-login.tsx` (one stray
   line over `printWidth`). Ran `prettier --write` across the three
   files this PR touches — no behaviour change, just formatting.

Signed-off-by: InstaZDLL <github.105mh@8shield.net>
InstaZDLL added a commit to InstaZDLL/WaveFlow that referenced this pull request May 31, 2026
…p.1b) (#190)

* feat(server-auth): oauth-loopback browser handshake (Phase 1.f.desktop.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>

* fix(server-auth): reject token+error mix in oauth callback

@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>

---------

Signed-off-by: InstaZDLL <github.105mh@8shield.net>
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: 2

🤖 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/routes/sign-in.test.ts`:
- Around line 20-38: The test is missing a rejection case for the sibling-prefix
attack noted in sign-in.tsx; update the it.each list in
src/routes/sign-in.test.ts to include '/desktop-login-evil' (and any similar
sibling-prefix strings) so safeContinueTarget is asserted to return '/' for that
input, ensuring safeContinueTarget continues to block paths that start with the
trusted prefix plus extra characters (refer to safeContinueTarget and the
'/desktop-login' check in sign-in.tsx).

In `@src/routes/sign-in.tsx`:
- Line 44: La validation actuelle utilise
parsed.pathname.startsWith('/desktop-login') et accepte des frères non désirés
comme '/desktop-login-evil'; remplacez cette garde pour n'autoriser que la route
exacte ou son sous-arbre en changeant la condition sur parsed.pathname pour
vérifier soit exact ('/desktop-login') soit un préfixe suivi d'un slash (par ex.
startsWith('/desktop-login/')), et renvoyer '/' sinon; localisez la ligne
contenant parsed.pathname.startsWith('/desktop-login') et remplacez-la par la
condition composée (exact ou sous-arbre).
🪄 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: 0776448e-6a9a-44a9-a164-18f48cd3a430

📥 Commits

Reviewing files that changed from the base of the PR and between c8b5733 and 6834184.

📒 Files selected for processing (3)
  • src/routes/desktop-login.tsx
  • src/routes/sign-in.test.ts
  • src/routes/sign-in.tsx

Comment thread src/routes/sign-in.test.ts
Comment thread src/routes/sign-in.tsx Outdated
@coderabbitai on PR #18 flagged that startsWith('/desktop-login')
accepts sibling paths like /desktop-login-evil and
/desktop-loginXYZ. Origin is already pinned to localhost so it isn't
an open-redirect, but the prefix gate's intent is "the route or its
subtree", and the sibling-prefix slip contradicts that. Anchor on
either exact match or a trailing slash; pin the regression with two
new test cases.

Signed-off-by: InstaZDLL <github.105mh@8shield.net>
@InstaZDLL InstaZDLL merged commit 1218a9a into main May 31, 2026
10 checks passed
@InstaZDLL InstaZDLL deleted the feat/1-f-1b-desktop-login branch May 31, 2026 15:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant