Skip to content

feat(auth): loopback OAuth redirect with deep-link fallback#2511

Merged
senamakel merged 9 commits into
tinyhumansai:mainfrom
senamakel:feat/loopback-oauth-redirect
May 23, 2026
Merged

feat(auth): loopback OAuth redirect with deep-link fallback#2511
senamakel merged 9 commits into
tinyhumansai:mainfrom
senamakel:feat/loopback-oauth-redirect

Conversation

@senamakel
Copy link
Copy Markdown
Member

@senamakel senamakel commented May 23, 2026

Summary

  • Add an RFC 8252 loopback HTTP listener (http://127.0.0.1:53824/auth) as the preferred desktop OAuth redirect target, with the existing openhuman:// deep link as automatic fallback.
  • New Tauri commands start_loopback_oauth_listener / stop_loopback_oauth_listener spawn a one-shot listener per login click, validate a state nonce, emit a loopback-oauth-callback event, and tear themselves down.
  • OAuthProviderButton tries loopback first; on bind failure (port in use, not in Tauri) it transparently falls back to the legacy deep-link path — no regression.

Problem

Desktop deep links (openhuman://auth) are unpredictable on Linux/Windows and rely on single-instance forwarding through a named pipe (#1130). RFC 8252 recommends loopback HTTP redirects for native apps because they're robust across platforms, work with system browsers, and don't require OS-level URL-scheme registration. We had no loopback option, so any deep-link failure (xdg-utils missing, broken ms- registration, etc.) stranded users at the system browser with no path back into the app.

Solution

  • New module app/src-tauri/src/loopback_oauth.rs — a minimal tokio TCP accept loop (no axum/hyper added). Binds 127.0.0.1:53824 on demand, generates a 32-char hex state nonce, accepts loopback-only connections, validates state on /auth?...&state=, emits the callback URL to the frontend, and shuts down. 300 s timeout, supersedes any prior listener on a new start.
  • Frontend wrapper app/src/utils/loopbackOauthListener.ts returns null on bind failure so callers can fall back; exposes redirectUri (state pre-appended), awaitCallback(), and cancel().
  • desktopDeepLinkListener.ts exports handleDeepLinkUrls so the loopback path reuses the same auth/token-exchange/CoreStateProvider commit logic — token handling stays in one place.
  • OAuthProviderButton.tsx requests a loopback handle before opening the browser, appends redirectUri=<loopback> to the backend login URL, and feeds the eventual callback through handleDeepLinkUrls as a synthetic openhuman://auth?….

Design choices (clarified up front):

  • Fixed pre-registered port (53824) rather than ephemeral — backend redirect_uri allowlisting is typically static.
  • Spawn-on-demand per click rather than always-on — no idle HTTP attack surface.
  • Auth/login flow only — skill OAuth and payment paths are unchanged.
  • Prefer loopback, fall back to deep link automatically — no UI knob.

Submission Checklist

  • Tests added or updated (happy path + at least one failure / edge case) per Testing Strategy
  • N/A: changed lines are mostly new module code with Vitest + cargo unit tests covering parsers, fallback, callback, cancel paths; full diff-cover gate will be enforced by CI
  • N/A: behaviour adds a new optional redirect path, no matrix row added/removed/renamed
  • N/A: no matrix row touched
  • No new external network dependencies introduced (mock backend used per Testing Strategy)
  • N/A: loopback fallback doesn't change release-cut manual smoke surfaces (existing deep-link path remains primary until backend wires the new redirectUri)
  • N/A: no linked issue

Impact

  • Desktop only (Windows / macOS / Linux). Tauri shell adds one tokio task per OAuth click while waiting for the callback; idle cost is zero.
  • Backend dependency: ${backendUrl}/auth/<provider>/login must accept a redirectUri query param and echo state back on the redirect for the loopback path to actually fire. Until that's wired backend-side, every login transparently falls back to the existing openhuman://auth deep link.
  • Loopback origin is restricted to 127.0.0.1 peers; state nonce guards against a hostile page on the same loopback origin faking a callback.
  • No new crate dependencies; reuses existing tokio, rand, hex, serde.

Pre-push hook note: the local pre-push hook applied two whitespace-only formatting auto-fixes (one in loopback_oauth.rs, one in loopbackOauthListener.ts) — committed as chore: apply auto-fixes. No --no-verify was used.

Related

  • Closes:
  • Follow-up PR(s)/TODOs: backend change to honor redirectUri query param + echo state on auth login redirect

AI Authored PR Metadata (required for Codex/Linear PRs)

Linear Issue

  • Key: N/A
  • URL: N/A

Commit & Branch

  • Branch: feat/loopback-oauth-redirect
  • Commit SHA: 8e5cb4e

Validation Run

  • pnpm --filter openhuman-app format:check (pre-push hook auto-applied two whitespace fixes, then passed)
  • pnpm typecheck
  • Focused tests: cargo test --lib loopback_oauth (4 passing), pnpm test (3116 passing, 3 skipped — new loopbackOauthListener.test.ts included), pnpm test:e2e login-flow.spec.ts (12/12), pnpm test:e2e smoke.spec.ts (3/3 + 1 skipped)
  • Rust fmt/check (if changed): cargo check on Tauri shell (clean)
  • Tauri fmt/check (if changed): cargo check on Tauri shell (clean)

Validation Blocked

  • command: N/A
  • error: N/A
  • impact: N/A

Behavior Changes

  • Intended behavior change: desktop OAuth login now prefers a loopback HTTP redirect; falls back to existing openhuman:// deep link when loopback bind fails.
  • User-visible effect: none until the backend honors the redirectUri query param; current behavior is unchanged.

Parity Contract

  • Legacy behavior preserved: yes — bind failure or non-Tauri runtime returns null and the original deep-link URL is sent verbatim.
  • Guard/fallback/dispatch parity checks: loopback callback URL is rewritten to openhuman://auth?… and dispatched through the same handleDeepLinkUrls handler used by the deep-link plugin, so token consume + CoreStateProvider commit logic is identical.

Duplicate / Superseded PR Handling

  • Duplicate PR(s): none
  • Canonical PR: this PR
  • Resolution: N/A

Summary by CodeRabbit

  • New Features

    • Desktop OAuth now prefers a local loopback HTTP redirect on Tauri builds, with start/stop listener support and automatic routing of the callback into the app; falls back to deep-link when unavailable.
  • Improvements

    • Deep-link URL handler exported for reuse; OAuth button integrates loopback flow and handles callback failures gracefully without disrupting login.
  • Tests

    • Expanded coverage for loopback startup, callback delivery, timeouts, cancellation and error handling.

Review Change Stack

senamakel added 2 commits May 22, 2026 20:08
Adds an RFC 8252 loopback HTTP listener as the preferred desktop OAuth
redirect target, with the existing `openhuman://` deep link as fallback.

The Tauri shell binds `127.0.0.1:53824/auth` on demand per login click,
generates a state nonce, and emits a `loopback-oauth-callback` event when
the browser hits it; the frontend funnels the callback URL through the
same `handleDeepLinkUrls` handler used by the deep-link path so token
exchange and CoreStateProvider commit logic stays in one place. If bind
fails (port in use, not in Tauri, etc.) we transparently fall back to
the legacy `openhuman://` redirect — no regression.

Backend dependency: `${backendUrl}/auth/<provider>/login` must accept a
`redirectUri` query param and echo `state` back on the callback for the
loopback path to actually fire.
@senamakel senamakel requested a review from a team May 23, 2026 03:26
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 23, 2026

Warning

Review limit reached

@senamakel, we couldn't start this review because you've used your available PR reviews for now.

Your plan currently allows 3 reviews/hour. Refill in 8 minutes and 15 seconds.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more review capacity refills, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than trial, open-source, and free plans. In all cases, review capacity refills continuously over time.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: ee758795-9b8a-4b35-bba1-3ca71946dbd6

📥 Commits

Reviewing files that changed from the base of the PR and between 6a54da3 and d034634.

📒 Files selected for processing (2)
  • app/src/lib/i18n/chunks/de-5.ts
  • app/src/utils/__tests__/loopbackOauthListener.test.ts
📝 Walkthrough

Walkthrough

Adds a Tauri Rust module that spawns a one-shot loopback HTTP listener for OAuth callbacks, a TypeScript wrapper exposing await/cancel handles, integrates loopback-based callbacks into the OAuth component on desktop, and exports the deep-link handler for routing received callbacks.

Changes

Loopback OAuth Desktop Callback Flow

Layer / File(s) Summary
Rust loopback listener core
app/src-tauri/src/loopback_oauth.rs, app/src-tauri/src/lib.rs
Tauri commands start_loopback_oauth_listener / stop_loopback_oauth_listener implement a one-shot HTTP server on 127.0.0.1:<port>/auth that validates a 32-hex state nonce, responds with an HTML success page, emits loopback-oauth-callback with the full callback URL, supports cancellation/replacement, and includes unit tests.
TypeScript loopback listener wrapper
app/src/utils/loopbackOauthListener.ts, app/src/utils/__tests__/loopbackOauthListener.test.ts
startLoopbackOauthListener invokes the Rust command, returns a LoopbackHandle (redirectUri, state, awaitCallback, cancel), listens for loopback-oauth-callback events, enforces timeouts, and cancels the listener. Tests mock Tauri invoke/listen and verify startup failure, success, callback resolution, cancellation, listen rejection, and timeout.
OAuth component integration and deep-link routing
app/src/components/oauth/OAuthProviderButton.tsx, app/src/utils/desktopDeepLinkListener.ts, app/src/components/oauth/__tests__/*
OAuth initiation optionally starts the loopback listener on Tauri, appends the redirectUri/state to the OAuth URL, races loopback callback handling against focus/timeout fallback, converts loopback callback URLs into openhuman://auth deep-links, and routes them via the now-exported handleDeepLinkUrls. Component tests mock loopback and deep-link handlers.

Sequence Diagram

sequenceDiagram
  participant Browser
  participant LoopbackListener as LoopbackListener(Rust)
  participant TauriEvent as TauriEventEmitter
  participant Frontend

  Browser->>LoopbackListener: GET 127.0.0.1:port/auth?state=...&code=...
  LoopbackListener->>LoopbackListener: parse request, validate state
  LoopbackListener->>Browser: 200 "Signed in" HTML
  LoopbackListener->>TauriEvent: emit loopback-oauth-callback (callback URL)
  TauriEvent->>Frontend: frontend receives callback URL
  Frontend->>Frontend: convert to openhuman:// deep-link and route
Loading

Estimated Code Review Effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly Related PRs

Suggested Reviewers

  • M3gA-Mind

Poem

🐰 I stood by localhost with a key,
Nonces twinkled, callbacks hopped to me,
Rust opened a port and sent an event,
Frontend turned it into a deep-link sent,
Sign-in hopped home — joy and glee!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 73.33% 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 The title 'feat(auth): loopback OAuth redirect with deep-link fallback' directly and accurately describes the main changes in the PR, clearly summarizing the primary feature addition.
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 Check skipped - CodeRabbit’s high-level summary is enabled.

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


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

@coderabbitai coderabbitai Bot added rust-core Core Rust runtime in src/: CLI, core_server, shared infrastructure. working A PR that is being worked on by the team. labels May 23, 2026
Copy link
Copy Markdown
Contributor

@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

🧹 Nitpick comments (1)
app/src/utils/loopbackOauthListener.ts (1)

67-83: ⚡ Quick win

Use the frontend debug logger instead of console.warn here.

This helper adds new frontend logging via console.warn, which drifts from the repo’s namespaced debug pattern and makes these messages harder to correlate with the rest of the auth flow.

🔧 Example cleanup
+import debug from 'debug';
 import { invoke } from '`@tauri-apps/api/core`';
 import { listen, type UnlistenFn } from '`@tauri-apps/api/event`';

 import { isTauri } from './tauriCommands/common';

+const warnLog = debug('oauth:loopback:warn');
+
 ...
   try {
     result = await invoke<StartResult>('start_loopback_oauth_listener', { port, timeoutSecs });
   } catch (err) {
-    console.warn('[loopback-oauth] start failed, falling back to deep link', err);
+    warnLog('start failed, falling back to deep link %o', err);
     return null;
   }
 ...
   const stop = async () => {
     try {
       await invoke('stop_loopback_oauth_listener');
     } catch (err) {
-      console.warn('[loopback-oauth] stop failed', err);
+      warnLog('stop failed %o', err);
     }
   };

As per coding guidelines, "Use namespaced debug function in TypeScript frontend with dev-only detail logging."

🤖 Prompt for 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.

In `@app/src/utils/loopbackOauthListener.ts` around lines 67 - 83, Replace the two
console.warn calls with the repo’s namespaced frontend debug logger: import and
instantiate the debug logger for this module (e.g. const debug =
createDebug('frontend:loopback-oauth') or the project’s standard debug factory),
then call debug('[loopback-oauth] start failed, falling back to deep link', err)
in the catch around invoke<StartResult>('start_loopback_oauth_listener', ...)
and debug('[loopback-oauth] stop failed', err) in the catch inside stop(); keep
the existing behavior/return values and do not change invoke, StartResult,
appendState or stop signatures.
🤖 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 `@app/src-tauri/src/loopback_oauth.rs`:
- Line 39: The current static ACTIVE_LISTENER races: a stale task can
unconditionally clear the global sender and remove a newer listener; change
ACTIVE_LISTENER to store a (listener_id: u64, sender: oneshot::Sender<()>) tuple
(e.g. Mutex<Option<(u64, oneshot::Sender<()>)>>) and have
start_loopback_oauth_listener generate/increment a new id when installing a
sender, have the exiting/cancel path and stop_loopback_oauth_listener compare
the id they think they own against the id currently stored and only clear/remove
the entry when the ids match; update all code that reads/writes ACTIVE_LISTENER
(creation in start_loopback_oauth_listener, cancellation in
stop_loopback_oauth_listener, and the task-exit path around Line 152) to use and
check the id so a stale task cannot wipe out a newer sender.
- Around line 240-241: The emitted callback URL omits the bound port so the
frontend rewrite doesn't match; change the construction of callback_url (used in
app.emit(LOOPBACK_CALLBACK_EVENT, CallbackPayload { url: callback_url })) to
include the listener's actual port (e.g., format!("http://127.0.0.1:{}{}", port,
target) or by calling listener.local_addr().port()) so the emitted URL is
"http://127.0.0.1:<port><target>" and will correctly be rewritten to the
openhuman:// scheme.

---

Nitpick comments:
In `@app/src/utils/loopbackOauthListener.ts`:
- Around line 67-83: Replace the two console.warn calls with the repo’s
namespaced frontend debug logger: import and instantiate the debug logger for
this module (e.g. const debug = createDebug('frontend:loopback-oauth') or the
project’s standard debug factory), then call debug('[loopback-oauth] start
failed, falling back to deep link', err) in the catch around
invoke<StartResult>('start_loopback_oauth_listener', ...) and
debug('[loopback-oauth] stop failed', err) in the catch inside stop(); keep the
existing behavior/return values and do not change invoke, StartResult,
appendState or stop signatures.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 1712395d-dd12-4744-8913-d55abf3d5269

📥 Commits

Reviewing files that changed from the base of the PR and between e9c9374 and 8e5cb4e.

📒 Files selected for processing (6)
  • app/src-tauri/src/lib.rs
  • app/src-tauri/src/loopback_oauth.rs
  • app/src/components/oauth/OAuthProviderButton.tsx
  • app/src/utils/__tests__/loopbackOauthListener.test.ts
  • app/src/utils/desktopDeepLinkListener.ts
  • app/src/utils/loopbackOauthListener.ts

Comment thread app/src-tauri/src/loopback_oauth.rs Outdated
Comment thread app/src-tauri/src/loopback_oauth.rs Outdated
- Tag ACTIVE_LISTENER with a monotonic id so a superseded task can't
  wipe out the newer sender installed by the start that cancelled it.
- Emit the callback URL with the bound port. The frontend rewrite
  matches /^https?:\/\/127\.0\.0\.1:\d+\/auth/, so omitting the port
  prevented the loopback callback from being rewritten to openhuman://
  and stalled the desktop auth flow.
coderabbitai[bot]
coderabbitai Bot previously approved these changes May 23, 2026
senamakel added 2 commits May 22, 2026 21:05
Bring diff-coverage above the 80% gate by exercising the previously
untested branches in OAuthProviderButton's loopback wiring and
loopbackOauthListener's stop/listen/timeout paths.
@coderabbitai coderabbitai Bot added the feature Net-new user-facing capability or product behavior. label May 23, 2026
Copy link
Copy Markdown
Contributor

@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 `@app/src/utils/__tests__/loopbackOauthListener.test.ts`:
- Around line 83-95: The test "cancel swallows stop_loopback_oauth_listener
failure" creates a console.warn spy but restores it only at the end, risking
leakage if an assertion throws; update the test around
startLoopbackOauthListener/handle.cancel to ensure the spy is always restored by
wrapping the assertions in a try/finally (or using afterEach) so
warn.mockRestore() runs regardless of failures, referencing the test, the
console.warn spy (warn), startLoopbackOauthListener(), and handle.cancel() to
locate the change.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f7d17e25-7c4d-4fac-afd2-133bc8a3ceff

📥 Commits

Reviewing files that changed from the base of the PR and between cdbf108 and 709ad38.

📒 Files selected for processing (2)
  • app/src/components/oauth/__tests__/OAuthProviderButton.test.tsx
  • app/src/utils/__tests__/loopbackOauthListener.test.ts

Comment thread app/src/utils/__tests__/loopbackOauthListener.test.ts
coderabbitai[bot]
coderabbitai Bot previously approved these changes May 23, 2026
Lines 529-550 duplicated the MCP server translation block already
present at lines 211-235, breaking tsc (TS1117) which cascaded into
Type Check TypeScript and the E2E build step.

Keep the earlier (more complete) block; drop the trailing duplicates.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature Net-new user-facing capability or product behavior. rust-core Core Rust runtime in src/: CLI, core_server, shared infrastructure. working A PR that is being worked on by the team.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant