feat(app+backend): local-development sign-in for contributors without OAuth - #11784
feat(app+backend): local-development sign-in for contributors without OAuth#11784formed2forge wants to merge 5 commits into
Conversation
|
All 19 CI checks pass. Pinging for initial review — happy to answer questions about the approach. |
|
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
Tests — good target selection. What needs a human decision Production is structurally protected; the open question is the dev/staging trust boundary. Any deployment that sets Minor, non-blocking: in 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 |
9ce64a7 to
2fad6bb
Compare
Git-on-my-level
left a comment
There was a problem hiding this comment.
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:151 — await _signIn(onSignIn) — now fails both:
- Dart Analyze & Tests:
MISSING_REQUIRED_ARGUMENTforauthProviderandcredentialatauth_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) whenFIREBASE_AUTH_EMULATOR_HOSTis unset or blank;get_user→create_user(email_verified=True)→ synccreate_custom_tokenmirrors the existing_generate_custom_tokenconvention (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 thatreview_status: reviewedis 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 throwsStateErrorbefore 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;isLocalDevProfilecorrectly delegates toEnv.profilerather than flavor logic.app/lib/pages/onboarding/auth.dart— the "Sign in (local dev)" affordance renders only underisLocalDevProfileand 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 everyEnvironment.valuesentry 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.
2fad6bb to
2a36970
Compare
Git-on-my-level
left a comment
There was a problem hiding this comment.
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:151 — await _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_ARGUMENTforcredentialandauthProvideratauth_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.
|
Fixed the |
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.
|
The compile fix is verified on this head: Checked against the head tree: 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 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. |
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
62e0344 to
9ae7d36
Compare
|
Rebased onto BasedHardware/omi
Was DIRTY; now MERGEABLE. route_policy_manifest.yaml: kept HEAD policy and POST /v1/auth/local-dev/custom-token. Independent of #12453. |
|
Verified the rebase onto Re-checked a few things on the final tree:
The one item still open before merge is unchanged: maintainer sign-off on the trust boundary for by AI on behalf of David — if you need David’s attention urgently, please @Git-on-my-level and escalate with |
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.
POST /v1/auth/local-dev/custom-tokenmints a Firebase custom token against the local Auth emulator. Structurally gated, not feature-flagged: it only exists whenFIREBASE_AUTH_EMULATOR_HOSTis set, so it 404s (not 403) on any real deployment — indistinguishable from a route that was never registered.getIdTokenResult(true)against the emulator, which was measured to hang indefinitely on-device rather than error (see commit body for detail).public/noneauth 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 routeapp/test/unit/auth_service_local_dev_test.dart,app/test/unit/local_dev_session_recovery_test.dart— new unit testsPOST /v1/auth/local-dev/custom-token 200 OKfollowed by authenticated onboarding callsFailure-Class: none