Skip to content

feat(app+backend): local-development sign-in for contributors without OAuth - #11784

Open
formed2forge wants to merge 5 commits into
BasedHardware:mainfrom
formed2forge:feat/local-dev-emulator-auth
Open

feat(app+backend): local-development sign-in for contributors without OAuth#11784
formed2forge wants to merge 5 commits into
BasedHardware:mainfrom
formed2forge:feat/local-dev-emulator-auth

Conversation

@formed2forge

@formed2forge formed2forge commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a local-development sign-in path so contributors without a shared OAuth client (Google/Apple) can still sign in against the local-dev harness's Firebase Auth emulator.

  • Backend: POST /v1/auth/local-dev/custom-token mints a Firebase custom token against the local Auth emulator. Structurally gated, not feature-flagged: it only exists when FIREBASE_AUTH_EMULATOR_HOST is set, so it 404s (not 403) on any real deployment — indistinguishable from a route that was never registered.
  • App: offers a "Sign in (local dev)" option on the onboarding auth screen when no OAuth provider is usable, and re-mints local-dev sessions out of band instead of relying on getIdTokenResult(true) against the emulator, which was measured to hang indefinitely on-device rather than error (see commit body for detail).
  • Route policy manifest entry added for the new endpoint (public/none auth placement, gated structurally rather than by request-time credential).

Context

Split out of the gold-standard iOS local-dev setup investigation — this is the piece that lets a contributor reach a signed-in app state at all when testing the documented local-dev pathway, verified alongside #11652 (CGNAT/ATS) and #11570 (UIScene) on both simulator and a physical device.

Test plan

  • backend/tests/unit/test_local_dev_auth.py — new unit tests for the emulator-gated route
  • app/test/unit/auth_service_local_dev_test.dart, app/test/unit/local_dev_session_recovery_test.dart — new unit tests
  • Verified live end-to-end this session: local-dev sign-in completed on both an iOS simulator and a physical iPhone over Tailscale, with the backend log showing POST /v1/auth/local-dev/custom-token 200 OK followed by authenticated onboarding calls

Review in cubic

Failure-Class: none

@formed2forge

Copy link
Copy Markdown
Contributor Author

All 19 CI checks pass. Pinging for initial review — happy to answer questions about the approach.

@Git-on-my-level

Copy link
Copy Markdown
Collaborator

Thanks for the careful write-up and the tight scope — the community-build OAuth problem is real, and the gating here is thoughtfully done. I verified the security reasoning end to end against the diff; one decision is left for a human maintainer before merge.

How the gate holds up

  • backend/routers/auth.py: local_dev_auth_enabled() reads FIREBASE_AUTH_EMULATOR_HOST, and the route answers 404 (not 403) when it is unset, so a production deployment never confirms the route exists. The structural claim checks out: when that variable is set, firebase_admin issues all tokens against the local Auth emulator, so a custom token minted at the end of local_dev_custom_token() cannot authenticate against real Firebase even if this code ran where it should not. The get_user → create_user (email_verified=True) → create_custom_token flow and the bytes-vs-str decode match the existing _generate_custom_token convention, including the sync (non-run_blocking) mint call.
  • backend/route_policy_manifest.yaml: the new entry is enum-consistent with the rest of the manifest (reviewed / public + placement: none / rate_limit: none / visibility: internal / surface: first_party_app) and honestly describes what ships. Two notes: review_status: reviewed is self-declared before any human has reviewed this route, and "public, unauthenticated, unthrottled" is exactly the shape a maintainer should consciously accept into the route inventory.
  • app/lib/services/auth_service.dart: signInWithLocalDevToken() throws StateError outside the local_dev profile before any network call, maps a 404 to an actionable harness-down message, and reuses the same custom-token sign-in as the OAuth path. _scheduleLocalDevRecovery() is genuinely narrow — local_dev only, only on AuthTokenTransientFailure, re-entrancy-guarded, and scheduled off the refresh call stack with the on-device deadlock rationale documented; production refresh behavior is untouched.
  • app/lib/providers/auth_provider.dart: isLocalDevProfile delegates to Env.profile instead of duplicating flavor logic, and the doc comment correctly names the server-side gate as authoritative. Surfacing the raw error string verbatim is the right call for a developer-only path.
  • app/lib/pages/onboarding/auth.dart: the sign-in affordance renders only under isLocalDevProfile, so production-family builds never see it, and the styling matches the sibling buttons.

Tests — good target selection. backend/tests/unit/test_local_dev_auth.py pins the gate itself (unset and blank/whitespace env values, 404-not-403, create_custom_token never reached when disabled, first-sign-in user creation, str/bytes decode). The Dart side pins the profile-vs-flavor distinction (auth_service_local_dev_test.dart, including the no-HTTP-stubbing StateError check) and the recovery blast radius (local_dev_session_recovery_test.dart walks every Environment.values entry).

What needs a human decision

Production is structurally protected; the open question is the dev/staging trust boundary. Any deployment that sets FIREBASE_AUTH_EMULATOR_HOST — including shared or Tailscale-reachable dev backends like the one in your test plan — exposes an unauthenticated, unthrottled endpoint that will mint a token for any uid on that network, creating users on demand. For a loopback harness that is fine; for anything reachable beyond the developer's own machine it means anyone who can reach the backend can be any user. A maintainer should sign off on where that boundary is expected to hold, and whether a dev-only shared secret or loopback bind check is worth adding.

Minor, non-blocking: in auth.py the blanket except Exception after get_user treats an emulator connectivity error the same as user-not-found, so an emulator hiccup surfaces as a failed create attempt (500) rather than a distinct error. Dev-only surface, so just a note.

This is a security-sensitive auth flow, so maintainer sign-off on the deployment trust boundary above is the one open item before merge — everything else looks in good shape.


by AI on behalf of David — if you need David’s attention urgently, please @Git-on-my-level and escalate with need human response.

@Git-on-my-level Git-on-my-level added security-review Touches auth, provider routing, secrets, or security-sensitive surfaces backend Backend Task (python) labels Aug 27, 2026
@cursor
cursor Bot force-pushed the feat/local-dev-emulator-auth branch from 9ce64a7 to 2fad6bb Compare September 2, 2026 03:10

@Git-on-my-level Git-on-my-level left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Follow-up from my earlier review: the head is unchanged, but main moved under this PR and the branch no longer compiles. Everything I verified before still holds; there is now one mechanical blocker.

Blocking: _signIn gained required parameters on main

#11539 (fdb45db, Aug 31) changed _signIn to require credential and authProvider. The new call in app/lib/providers/auth_provider.dart:151await _signIn(onSignIn) — now fails both:

  • Dart Analyze & Tests: MISSING_REQUIRED_ARGUMENT for authProvider and credential at auth_provider.dart:151
  • Android Compile Smoke: same line (Required named parameter 'authProvider' must be provided)

The fix is one line inside onLocalDevSignIn, and the non-null credential is already in scope from the guard above it:

await _signIn(onSignIn, credential: credential, authProvider: 'local_dev');

'local_dev' matches the provider string already used in _updateUserPreferences(credential, 'local_dev') in auth_service.dart.

Per-file re-check on this head and the new base

  • backend/routers/auth.py — still sound: local_dev_auth_enabled() 404s (not 403) when FIREBASE_AUTH_EMULATOR_HOST is unset or blank; get_usercreate_user(email_verified=True) → sync create_custom_token mirrors the existing _generate_custom_token convention (auth.py:1151) including the bytes-vs-str decode; every helper used (run_blocking, critical_executor, sanitize, cast) is already imported in this module.
  • backend/route_policy_manifest.yaml — entry is enum-consistent with neighboring routes; note unchanged from before that review_status: reviewed is self-declared here, and the public/unthrottled shape is precisely the maintainer decision item below.
  • app/lib/services/auth_service.dart — unaffected by the base change (it never calls _signIn): profile refusal still throws StateError before any network call, 404 still maps to the actionable harness-down message, and _scheduleLocalDevRecovery() remains narrow — local_dev profile only, transient-failure only, re-entrancy guarded, scheduled off the refresh call stack.
  • app/lib/providers/auth_provider.dart — line 151 is the only issue; isLocalDevProfile correctly delegates to Env.profile rather than flavor logic.
  • app/lib/pages/onboarding/auth.dart — the "Sign in (local dev)" affordance renders only under isLocalDevProfile and matches sibling button styling; no action needed.
  • app/test/unit/auth_service_local_dev_test.dart — still pins the refusal-before-network behavior (no HTTP stubbing) and the profile-vs-flavor distinction that keeps local_prod/mobile_beta out.
  • app/test/unit/local_dev_session_recovery_test.dart — still walks every Environment.values entry asserting no profile is both local_dev and production, which is the right blast-radius pin given the recovery needs live Firebase.
  • backend/tests/unit/test_local_dev_auth.py — gate contract intact: unset/blank/whitespace all disabled, 404-not-403, no mint when disabled, first-sign-in user creation, and the str/bytes decode case.

Requesting changes only for the compile break — once line 151 is fixed this returns to the state it was in on Aug 27 when all checks passed. Still open for a maintainer before merge (unchanged from my earlier review): any reachable deployment that sets FIREBASE_AUTH_EMULATOR_HOST exposes unauthenticated, unthrottled token minting for arbitrary uids, so sign-off is needed on where that boundary is expected to hold (loopback-only harness vs shared/Tailscale-reachable dev backends) and whether a dev-only shared secret or loopback bind check is worth adding.


by AI on behalf of David — if you need David’s attention urgently, please @Git-on-my-level and escalate with need human response.

@Git-on-my-level Git-on-my-level added the needs-maintainer-review Needs a human maintainer to sign off before merge label Sep 2, 2026
@cursor
cursor Bot force-pushed the feat/local-dev-emulator-auth branch from 2fad6bb to 2a36970 Compare September 2, 2026 15:31

@Git-on-my-level Git-on-my-level left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The rebase onto current main is appreciated — but the one blocking item from the last review is still open. The rebase moved main underneath without touching any of this PR's files (I diffed the old and new heads: all eight are byte-identical), and line 151 still does not compile.

Still blocking: auth_provider.dart:151await _signIn(onSignIn)

_signIn on this base (auth_provider.dart:216) requires credential and authProvider; the call inside onLocalDevSignIn passes neither. Both failing checks on the new head point at the same line:

  • Dart Analyze & Tests: MISSING_REQUIRED_ARGUMENT for credential and authProvider at auth_provider.dart:151
  • Android Compile Smoke: Required named parameter 'authProvider' must be provided (same line)

Same one-line fix as before — the non-null credential is already in scope from the guard directly above:

await _signIn(onSignIn, credential: credential, authProvider: 'local_dev');

Everything else is unchanged from the state verified in my earlier reviews, so once line 151 is fixed this should return to the all-checks-passing state it had before #11539 landed. The open maintainer item is likewise unchanged: sign-off on the trust boundary for the unauthenticated, unthrottled token-minting endpoint on any deployment that sets FIREBASE_AUTH_EMULATOR_HOST (loopback harness vs shared/Tailscale-reachable dev backends, and whether a dev-only shared secret or loopback bind check is warranted).


by AI on behalf of David — the pending human item is sign-off on that dev-trust boundary for the token-minting endpoint; the compile fix itself needs no maintainer input.

@formed2forge

Copy link
Copy Markdown
Contributor Author

Fixed the _signIn call in onLocalDevSignIn to pass the now-required credential and authProvider arguments (both already in scope at the call site). Should clear Dart analyze and Android compile. Ready for re-review.

@Git-on-my-level
Git-on-my-level dismissed stale reviews from themself September 4, 2026 09:47

Dismissed as stale: the blocking compile issue (_signIn missing required credential/authProvider at the onLocalDevSignIn call site) is fixed on head 62e0344 — verified against _signIn's required named parameters. Re-review outcome is in the PR comments.

@Git-on-my-level

Copy link
Copy Markdown
Collaborator

The compile fix is verified on this head: onLocalDevSignIn now calls await _signIn(onSignIn, credential: credential, authProvider: 'local_dev'), which matches _signIn's required named parameters on current main — exactly the one-line change the earlier reviews asked for, so I've dismissed those change requests.

Checked against the head tree: _signIn (auth_provider.dart:241) requires both credential and authProvider, the non-null credential is already guarded in scope directly above the call, and 'local_dev' matches the provider string used in _updateUserPreferences(credential, 'local_dev') (auth_service.dart:722). The compare between the previous head and this one shows that single line is the entire delta — everything else is the state verified in the earlier reviews.

One note: no CI checks have reported on this new head yet. The previous head's two failing checks both pointed at the line just fixed, so they should clear once CI runs.

Still open before merge (unchanged, and the only remaining item): maintainer sign-off on the trust boundary for POST /v1/auth/local-dev/custom-token — any reachable deployment that sets FIREBASE_AUTH_EMULATOR_HOST exposes unauthenticated, unthrottled custom-token minting for arbitrary uids. The structural gate is sound for production (404 when unset; emulator-minted tokens cannot authenticate against real Firebase), but where that boundary is expected to hold — loopback-only harness vs shared/Tailscale-reachable dev backends, and whether a dev-only shared secret or loopback bind check is worth adding — is a deployment-policy decision rather than a code-correctness one.

Thanks for the quick turnaround on the fix.


by AI on behalf of David — leaving for human maintainer review on that dev-trust boundary decision; no code changes are requested.

formed2forge and others added 5 commits September 4, 2026 13:40
Community builds cannot complete a real OAuth flow. Google and Apple issue OAuth
clients against the official bundle id, and a community build is deliberately
signed with a suffixed one, so this router's provider redirect flow is
unreachable for them. On a local harness both providers currently fail with
"Google client ID not configured" / "Apple client ID not configured" (auth.py:779
and :805) because the harness sets neither variable. There was no way to sign in
to a local harness from a community build at all.

Adds POST /v1/auth/local-dev/custom-token, which mints a Firebase custom token
against the local Auth emulator and returns the same `custom_token` shape the
OAuth token-exchange path returns, so the client reuses one sign-in code path.

The gate is FIREBASE_AUTH_EMULATOR_HOST and it is structural, not a feature flag:
when it is set, firebase_admin issues tokens against the local Auth emulator, so
a token minted here cannot authenticate against real Firebase even if this code
somehow ran in production. When it is unset the endpoint answers 404 rather than
403, so it is indistinguishable from a route that was never registered and cannot
be discovered by probing.

Tests cover the gate first and the happy path second, since the gate is the risk:
unset, blank and whitespace values all read as disabled; the disabled path is
asserted to mint nothing at all rather than merely to raise; and 404-not-403 is
asserted explicitly so a future refactor cannot soften it into an informative
error. The first-sign-in case (a uid with no emulator user yet) is covered as the
unmigrated-principal case — without it the endpoint would fail closed exactly
when it is needed.

Verification: BACKEND_UNIT_TEST_FILE_LIST=... bash test.sh -> 9 passed.

Not wired to any client in this commit.

Failure-Class: none

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011qmdS9crg5qFhan7PCbWvZ
Wires the client to POST /v1/auth/local-dev/custom-token from the preceding
commit, so a community build can sign in to a local harness. Google and Apple
issue OAuth clients against the official bundle id and a community build is
signed with a suffixed one, so both provider buttons dead-end on a local harness
with "client ID not configured" from the backend.

signInWithLocalDevToken() fetches a custom token and hands it to
signInWithCustomToken, which is the same call the OAuth flow ends with, so the
session, preference update and downstream state are identical to a real sign-in
rather than a parallel code path.

The affordance is only rendered when Env.profile is localDev, and the service
method refuses to issue the request outside that profile. Neither is the real
gate: the backend endpoint 404s unless it is bound to a Firebase Auth emulator,
which is structural rather than a flag. The client-side checks exist so a
production build never makes the call at all.

Backend errors are surfaced verbatim rather than replaced with a generic
"authentication failed". This path is for developers, and the message already
names the actual problem — harness down, wrong profile, no emulator — which is
precisely the diagnosis a generic string would throw away.

Verification: flutter test test/unit -> 755 passed, including 2 new tests. The
guard test asserts the refusal happens before any network call by stubbing no
HTTP at all, and a second test pins the gate to the local_dev *profile* rather
than the flavour name, so it cannot drift into admitting local_prod or
mobile_beta.

Not yet exercised end-to-end on device.

Failure-Class: none

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011qmdS9crg5qFhan7PCbWvZ
…ng them

Local dev has an escape hatch production does not: the harness mints a fresh
custom token on demand, so a session can be replaced rather than refreshed. This
uses it when a refresh fails.

That matters because getIdTokenResult(true) is not reliable against the Auth
emulator. Measured on iPhone 17 Pro / iOS 27.0, the forced refresh never returned
— three bounded 8s attempts, all timing out, on every authenticated request —
while signInWithCustomToken against the same emulator succeeded every time.
Re-minting uses the path that demonstrably works and avoids the one that does not.

Scheduled, not awaited, and deliberately off the current call stack. The first
version awaited it inline from getIdToken() and deadlocked on device: a probe
showed it entering signInWithLocalDevToken() and never returning, with no HTTP
request ever leaving the app, while the identical call from the sign-in button
succeeded. Re-entering sign-in from inside the refresh call stack is the
difference. The caller now gets its failure immediately and the next request
picks up the re-minted session.

Ruled out before resorting to a workaround: the SDK does route the refresh
through the emulator (FirebaseAuth SecureTokenRequest.swift:129-130 builds
exactly the URL that answers instantly to curl); the device reaches the public
internet; the fake local API key would fail fast rather than hang if the request
were reaching real Google; and ATS permits the plain-http call that sign-in
already makes to the same host and port. Why the SDK's refresh stalls is still
unexplained — this routes around it rather than fixing it.

Deliberately narrow: local_dev profile only, only after a transient refresh
failure, re-entrancy guarded. Production is untouched, because a failed refresh
must stay a failed refresh there — silently re-authenticating would hide exactly
the signal an expired or revoked session exists to give.

Verification: flutter test test/unit -> 764 passed, including 3 pinning the gate
(production-family builds cannot reach the recovery, the dev flavour resolves to
local_dev where it is permitted, and no environment resolves to both). The
recovery body needs a live FirebaseAuth and an HTTP round trip, so the gate is
what is unit-testable; end-to-end behaviour is verified on device.

Failure-Class: none

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011qmdS9crg5qFhan7PCbWvZ
…ndpoint

POST /v1/auth/local-dev/custom-token is intentionally unauthenticated at the
request level — it mints the token you'd otherwise not have — and is gated
structurally instead: it 404s unless FIREBASE_AUTH_EMULATOR_HOST is set, so it
does not exist as a route at all on a production deployment.

Failure-Class: none
…n onLocalDevSignIn

Since BasedHardware#11539 landed, _signIn requires `credential` (UserCredential) and
`authProvider` (String) as named required parameters. The onLocalDevSignIn
call site was left with the old zero-extra-args form, causing
MISSING_REQUIRED_ARGUMENT compile errors.

The credential is already in scope — it is the UserCredential? returned by
AuthService.instance.signInWithLocalDevToken() and is already null-checked
before the call. Pass it directly with authProvider: 'local_dev'.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P5TqoWB9unzNKthnD1pJUP
@cursor
cursor Bot force-pushed the feat/local-dev-emulator-auth branch from 62e0344 to 9ae7d36 Compare September 4, 2026 13:42
@formed2forge

Copy link
Copy Markdown
Contributor Author

Rebased onto BasedHardware/omi main 176a02fe0d.

  • old: 62e034419c
  • new: 9ae7d36e17
  • now 0 behind / 5 ahead, MERGEABLE

Was DIRTY; now MERGEABLE. route_policy_manifest.yaml: kept HEAD policy and POST /v1/auth/local-dev/custom-token. Independent of #12453.
No other changes in this push.

@Git-on-my-level

Copy link
Copy Markdown
Collaborator

Verified the rebase onto main (176a02fe9ae7d36e) is content-clean: backend/routers/auth.py, backend/tests/unit/test_local_dev_auth.py, and both Dart test files are blob-identical to the previously reviewed head, and app/lib/services/auth_service.dart (+97/−0) and backend/route_policy_manifest.yaml (+23/−0, the single /v1/auth/local-dev/custom-token entry) differ from upstream main only by this PR's own additions. Nothing rode in with the rebase, and the earlier "no CI checks reported on this head yet" note is resolved — Dart Analyze & Tests, the Backend Hermetic Merge Gate, and Hermetic Backend E2E are all green on 9ae7d36e.

Re-checked a few things on the final tree:

  • local_dev_custom_token() in backend/routers/auth.py: 404-when-disabled (not 403), blank/whitespace env handling, and the empty-uid 400 all hold, and the gate reads FIREBASE_AUTH_EMULATOR_HOST exactly as backend/main.py and backend/desktop_backend.py already do — consistent with the existing convention.
  • The registered path (router prefix /v1/auth + /local-dev/custom-token) matches the manifest entry, and the manifest honestly declares public/none auth — no gap between declaration and behavior.
  • App side stays production-safe: the AppEnvironmentProfile mapping excludes local_prod/mobile_beta/production, signInWithLocalDevToken() throws before any network call outside the local_dev profile, and _scheduleLocalDevRecovery() returns early outside that profile, so a failed refresh in production remains a failed refresh.

The one item still open before merge is unchanged: maintainer sign-off on the trust boundary for POST /v1/auth/local-dev/custom-token — any reachable deployment that sets FIREBASE_AUTH_EMULATOR_HOST exposes unauthenticated custom-token minting for arbitrary uids. That is inert against real Firebase (emulator-bound tokens cannot authenticate there), but it is a deliberate posture call for the local-dev harness that a human maintainer should own. Leaving for human maintainer review.


by AI on behalf of David — if you need David’s attention urgently, please @Git-on-my-level and escalate with need human response.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backend Backend Task (python) needs-maintainer-review Needs a human maintainer to sign off before merge security-review Touches auth, provider routing, secrets, or security-sensitive surfaces

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants