Pyric
Navigate

pyric/auth compatibility matrix

63.8% of the public API supported

95 of 149 public API

Status legend

Conforming — sandbox matches prod, locked by a passing probe Diverged (documented) — intentional difference with a written reason Not implemented yet — deliberately or pending Unverified — claim from docs that we haven't yet observed prod-side

getAuth(target) — initializer

getAuth(target)Returns a stable Auth handle for repeated calls with the same input
unit:sandbox-anonymous.test.ts + canonical Node register child (register-child.test.ts)
getAuth(target)getAuth(sandbox) dispatches to the sandbox backend
getAuth(target)Without sandbox package swapping, canonical firebase/auth imports remain Firebase and never enter this mirror
Direct mirror rejection is locked by unit:package-resolution.test.ts; an unswapped production-resolution observation is still needed
getAuth(target)After package swapping, bare getAuth() resolves the registered default sandbox app; without swapping, Firebase retains its app/no-app behavior when no default app exists
canonical Node register child (register-child.test.ts) + oracle: packages/conformance/observations/auth/auth-bare-getauth-no-default-app.json (code: 'app/no-app' against blockingfun, fb-js-sdk 12.13.0 — confirms unswapped Firebase behavior)
(wrap)
getAuth(target)auth.currentUser is a live getter, not a snapshot — reads through to the backend on every access

Multi-app Auth session topology

getAuth(app)Equal-config Firebase apps have independent active Auth sessions; signing in or out on one app does not change another app's currentUser
oracle: app-production-multi-app-topology (firebase 12.13.0, real Chromium against production) + twin replay: packages/pyric/test/app/production-multi-app-oracle.test.ts
browserLocalPersistence across same-named app instances in sibling tabsAfter the sibling receives the LOCAL persistence event, same-named Firebase app instances in sibling tabs keep independent active Auth sessions; an anonymous sign-in in one tab does not update the sibling tab's currentUser during the bounded post-signal observation window
oracle: app-production-cross-tab-auth-persistence (firebase 12.13.0, two real Chromium pages in one browser context) + served twin: packages/cli/test/e2e/app-multi-app.pw.ts

signInAnonymously(auth) — anonymous

signInAnonymously(auth)Returns UserCredential with providerId: null, operationType: 'signIn', and a User with isAnonymous: true, email: null, displayName: null
unit:sandbox-anonymous.test.ts, playground:auth-anonymous (bundled) + playground:auth-row-6-anon-credential-shape (one-claim) + oracle: packages/conformance/observations/auth/auth-anonymous-credential-providerid.json (providerId: null against blockingfun, fb-js-sdk 12.13.0). Prior matrix language said providerId: 'anonymous'; corrected after empirical observation. Sandbox aligned to prod in the same commit.
signInAnonymously(auth)Auto-generates a uid for fresh sign-ins (sandbox format: anonymous-{N})
unit:sandbox-anonymous.test.ts — prod uids are 28-char base64-ish; sandbox uses a readable counter for debuggability
format
signInAnonymously(auth)If an anonymous user is already signed in, returns the SAME user (no fresh uid mint)
unit:sandbox-anonymous.test.ts ("idempotent while signed in") — fix from #399
signInAnonymously(auth)After signOut, a subsequent signInAnonymously mints a fresh uid
unit:sandbox-anonymous.test.ts, playground:auth-anonymous (bundled) + playground:auth-row-9-anon-fresh-uid-after-signout (one-claim)
signInAnonymously(auth)Fires onAuthStateChanged exactly once per state transition (no same-value double-fire)
unit:sandbox-listeners.test.ts, playground:auth-anonymous (bundled) + playground:auth-row-10-onauthstatechanged-one-per-transition (one-claim) — fix from #399 + oracle: packages/conformance/observations/auth/auth-row-10-onauthstatechanged-one-per-transition.json (against blockingfun, fb-js-sdk 12.13.0: subscribe → 1 initial null fire → signInAnonymously → +1 → signOut → +1 → signInAnonymously → +1. eachTransitionFiredExactlyOnce: true — every transition produces exactly one fire)
signInAnonymously(auth)Writes through to sandbox.currentUser so rules engines see request.auth.uid
unit:sandbox-anonymous.test.ts, playground:auth-anonymous (bundled) + playground:auth-row-11-anon-uid-visible-to-rules (one-claim)
signInAnonymously(auth)Anonymous users persist across page reload via configured Persistence (prod only — sandbox has no persistence layer)
divergence: sandbox memory only; within one tab the user persists, across reload they don't

signInWithEmailAndPassword(auth, email, password) — password

signInWithEmailAndPassword(auth, email, password)Returns UserCredential with providerId: null (NOT 'password' — only OAuth/phone responses carry a providerId; upstream providerIdForResponse returns null for email/password), operationType: 'signIn', and a User with the stored uid + email
unit:sandbox-email-password.test.ts + oracle: packages/conformance/observations/auth/auth-createUser-operationType.json (providerId: null against blockingfun, fb-js-sdk 12.13.0). Prior matrix language said providerId: 'password'; corrected after the oracle contradicted it (AUTH-B2).
signInWithEmailAndPassword(auth, email, password)Throws auth/user-not-found when the email isn't seeded / hasn't been created
unit:sandbox-email-password.test.ts + oracle: packages/conformance/observations/auth/auth-user-not-found-error-code.json (code: 'auth/user-not-found' against blockingfun, fb-js-sdk 12.13.0; matches sandbox)
signInWithEmailAndPassword(auth, email, password)Throws auth/wrong-password when the password doesn't match
unit:sandbox-email-password.test.ts, playground:auth-email-password (bundled) + playground:auth-row-15-wrong-password-rejects (one-claim) + oracle: packages/conformance/observations/auth/auth-wrong-password-error-code.json (code: 'auth/wrong-password' against blockingfun, fb-js-sdk 12.13.0; matches sandbox)
signInWithEmailAndPassword(auth, email, password)An EMPTY password throws auth/missing-password (message "A non-empty password must be provided"), fired before the user-DB lookup so it can't be used to enumerate seeded emails. Upstream maps the MISSING_PASSWORD server error (core/errors.ts:92,282,563). ⚠ best-known semantics — message text not yet captured against a live project (STOP-flagged for an oracle pass; the .code is the load-bearing part).
signInWithEmailAndPassword(auth, email, password)Re-signing in after signOut returns the same uid (passwords persist within the sandbox lifetime)
playground:auth-email-password (bundled) + playground:auth-row-16-resignin-same-uid (one-claim)
signInWithEmailAndPassword(auth, email, password)Fires onAuthStateChanged with the new user once
unit:sandbox-listeners.test.ts + oracle: packages/conformance/observations/auth/auth-row-17-signin-email-password-fires-once.json (against blockingfun, fb-js-sdk 12.13.0: createUser → signOut → subscribe (1 initial null fire) → signInWithEmailAndPasswordfiresForSignIn: 1 with the signed-in uid, lastFireUidMatches: true)
signInWithEmailAndPassword(auth, email, password)Email validation (RFC 5322ish) — rejects empty, missing @, missing local-part, missing domain with auth/invalid-email. Runs on both signInWithEmailAndPassword and createUserWithEmailAndPassword before any user-DB lookup, so consumers shipping malformed input see the same error sandbox vs prod.
unit:sandbox-email-password.test.ts + oracle: packages/conformance/observations/auth/auth-row-18-invalid-email-error-code.json (code: 'auth/invalid-email', message Firebase: Error (auth/invalid-email). against blockingfun, fb-js-sdk 12.13.0)
signInWithEmailAndPassword(auth, email, password)Password strength requirements — rejects passwords shorter than 6 chars with auth/weak-password on createUserWithEmailAndPassword. Strength is NOT enforced on signInWithEmailAndPassword so previously-seeded weak passwords still let the user in (matches prod's separation of registration vs sign-in).
unit:sandbox-email-password.test.ts + oracle: packages/conformance/observations/auth/auth-row-19-weak-password-error-code.json (code: 'auth/weak-password', message Firebase: Password should be at least 6 characters (auth/weak-password). against blockingfun, fb-js-sdk 12.13.0; matrix language "≥6 chars per prod default" empirically confirmed)
signInWithEmailAndPassword(auth, email, password)Disabled accounts (sandbox.updateUser(…, {disabled: true})) reject sign-in with auth/user-disabled and prod's documented message (The user account has been disabled by an administrator.). Sandbox checks disabled BEFORE the password compare (anti-probing); the exact prod ordering of disabled-vs-wrong-password needs an oracle capture
code / ? ordering

createUserWithEmailAndPassword(auth, email, password) — register

createUserWithEmailAndPassword(auth, email, password)Creates a new user, signs them in automatically (currentUser becomes the new user)
playground:auth-email-password (bundled) + playground:auth-row-20-create-user-auto-signs-in (one-claim), unit:sandbox-email-password.test.ts
createUserWithEmailAndPassword(auth, email, password)Returns UserCredential with operationType: 'signIn' (NOT 'register' — matches prod)
unit:sandbox-email-password.test.ts + oracle: packages/conformance/observations/auth/auth-createUser-operationType.json (operationType: 'signIn' against blockingfun, fb-js-sdk 12.13.0; matches sandbox) (Structured evidence: oracle-conformance.test.ts)
createUserWithEmailAndPassword(auth, email, password)Throws auth/email-already-in-use when the email is already registered
unit:sandbox-email-password.test.ts + oracle: packages/conformance/observations/auth/auth-email-already-in-use-error-code.json (code: 'auth/email-already-in-use' against blockingfun, fb-js-sdk 12.13.0; matches sandbox)
createUserWithEmailAndPassword(auth, email, password)The created user has isAnonymous: false, email: <input>, displayName: null
unit:sandbox-email-password.test.ts + playground:auth-row-23-create-user-shape (one-claim)
createUserWithEmailAndPassword(auth, email, password)Fires onAuthStateChanged with the new user once
unit:sandbox-listeners.test.ts + oracle: packages/conformance/observations/auth/auth-row-24-createuser-fires-once.json (against blockingfun, fb-js-sdk 12.13.0: subscribe (1 initial null fire) → createUserWithEmailAndPasswordfiresForCreate: 1 with the newly-created uid, lastFireUidMatches: true)

signOut(auth)

signOut(auth)Sets currentUser to null synchronously after resolution
playground:auth-anonymous (bundled) + playground:auth-row-25-signout-currentuser-null (one-claim), unit:sandbox-anonymous.test.ts + oracle: packages/conformance/observations/auth/auth-row-25-signout-currentuser-null-sync.json (currentUserIsNullSync: true against blockingfun, fb-js-sdk 12.13.0 — auth.currentUser read in the synchronous continuation immediately after await signOut(auth) is already null, with no microtask/macrotask required to settle)
signOut(auth)Fires onAuthStateChanged with null exactly once
unit:sandbox-listeners.test.ts, playground:auth-anonymous (bundled) + playground:auth-row-26-signout-fires-null-once (one-claim) + oracle: packages/conformance/observations/auth/auth-row-26-signout-fires-null-once.json (against blockingfun, fb-js-sdk 12.13.0: subscribe (1 initial null fire) → signInAnonymouslysignOutfiresForSignOut: 1 with lastFireUidWasNull: true)
signOut(auth)Idempotent — signOut on already-signed-out user is a no-op (no listener fire)
playground:auth-signout-idempotent + oracle-confirmed: packages/conformance/observations/auth/auth-signout-idempotent.json (threw: false, redundantSignOutFiredListener: false against blockingfun)
signOut(auth)Clears the active session's persistence in prod; sandbox has no persistence
divergence: same memory-only constraint as the anonymous persistence row

onAuthStateChanged(auth, observer)

onAuthStateChanged(auth, observer)Fires immediately on subscribe with the current value (microtask-deferred)
unit:sandbox-listeners.test.ts + oracle: packages/conformance/observations/auth/auth-row-29-onauthstatechanged-initial-fire-timing.json (firedSynchronously: 0, firedAfterMicrotask: 1 against blockingfun, fb-js-sdk 12.13.0 — initial fire does NOT arrive in the synchronous tick of onAuthStateChanged(...); it lands after the first microtask flush)
onAuthStateChanged(auth, observer)Fires on every subsequent identity change
unit:sandbox-listeners.test.ts, playground:auth-anonymous + oracle: packages/conformance/observations/auth/auth-row-30-onauthstatechanged-fires-on-every-transition.json (against blockingfun, fb-js-sdk 12.13.0: subscribe → signInsignOutsignInsignOut, each of the 4 transitions produced exactly 1 fire; eachTransitionFiredExactlyOnce: true)
onAuthStateChanged(auth, observer)Dedups by uid: a same-uid update (profile-shape change, or a same-uid re-sign-in) does NOT re-fire onAuthStateChanged — only an actual uid change does. Mirrors upstream notifyAuthListeners's lastNotifiedUid gate (auth_impl.ts:718-723). (onIdTokenChanged still fires on those same-uid updates — see row 38a.)
unit:sandbox-uid-dedup.test.ts (locks AUTH-B7 / B8)
onAuthStateChanged(auth, observer)No duplicate fire when subscribe is followed by a synchronous setCurrentUser — dedup ensures observer sees the new value once, not twice. Sandbox-only concern: prod has no synchronous state-change API, so the dedup window can't be exercised against the cloud SDK; subscribe-then-async-signIn naturally fires twice (initial + new value) because the microtask between them flushes the initial fire
unit:sandbox-listeners.test.ts (regression test from #399), playground:auth-anonymous + oracle baseline: packages/conformance/observations/auth/auth-row-31-onauthstatechanged-no-dup-on-sync-transition.json (against blockingfun, fb-js-sdk 12.13.0: subscribe + async signInAnonymously in the same tick produces totalFires: 2{uid: null, ts: 0} then {uid: <user>, ts: ~400ms}. Confirms prod cannot exhibit the same-tick race; the dedup behavior remains a sandbox-only property)
onAuthStateChanged(auth, observer)Returned Unsubscribe removes the observer; subsequent state changes do NOT fire it
unit:sandbox-listeners.test.ts, playground:auth-listener-unsub (bundled) + playground:auth-row-32-unsubscribe-stops-fires (one-claim) + oracle: packages/conformance/observations/auth/auth-row-32-unsubscribe-stops-fires.json (against blockingfun, fb-js-sdk 12.13.0: subscribe → 1 fire on signInAnonymously → call unsub()signOut + signInAnonymously + signOut produce zero further fires; postUnsubFires: 0, unsubscribeStoppedFires: true)
onAuthStateChanged(auth, observer)Multiple subscribers all fire on each change
unit:sandbox-listeners.test.ts + oracle: packages/conformance/observations/auth/auth-row-33-multiple-subscribers-all-fire.json (against blockingfun, fb-js-sdk 12.13.0: two subscribers registered back-to-back each see 1 initial null fire, +1 on signInAnonymously, +1 on signOut; bothFiredOnSignIn: true, bothFiredOnSignOut: true)
onAuthStateChanged(auth, observer)Registry is array-backed (matches upstream util/subscribe.ts): the SAME observer fn registered N times produces N independent registrations that each fire, and one Unsubscribe removes exactly one registration. A resubscribe of a previously-unsubscribed fn fires its initial value again. (Per-registration initial-fire bookkeeping, not a shared per-observer dedup.)
unit:sandbox-listener-registry.test.ts (locks AUTH-B3 + AUTH-B4)
onAuthStateChanged(auth, observer)Unsubscribing during emission does not skip remaining subscribers (snapshotted iteration)
onAuthStateChanged(auth, observer)A throwing observer doesn't block other observers from firing
unit:sandbox-listeners.test.ts + oracle: packages/conformance/observations/auth/auth-row-35-throwing-observer-doesnt-block-others.json (secondObserverContinuedFiring: true against blockingfun, fb-js-sdk 12.13.0 — observer #1 throws on every call, observer #2 still counts the initial fire AND the post-sign-in fire)
onAuthStateChanged(auth, observer)Observer object form ({next, error, complete}) works alongside the function form
unit:sandbox-listeners.test.ts + oracle: packages/conformance/observations/auth/auth-row-36-observer-object-form-works.json (against blockingfun, fb-js-sdk 12.13.0: one observer as NextFn, another as {next, error, complete}. Both forms see 1 initial null fire, +1 on signInAnonymously, +1 on signOut; bothFormsFiredOnSignIn: true, bothFormsFiredOnSignOut: true)
onAuthStateChanged(auth, observer)Setting the same user twice does NOT double-fire (structural-equality no-op). Sandbox-internal setCurrentUser claim; the prod analog is signInAnonymously called twice in a row (per fix #399, the second call returns the same user).
unit:sandbox-listeners.test.ts + oracle: packages/conformance/observations/auth/auth-row-37-same-user-no-double-fire.json (against blockingfun, fb-js-sdk 12.13.0: first signInAnonymously fires the listener once; second call returns the same uid (sameUserAcrossCalls: true) and does NOT produce a fresh fire (secondSignInProducedFire: false). Prod also recognizes the same-user no-op)

onIdTokenChanged(auth, observer)

onIdTokenChanged(auth, observer)Fires on user change (sandbox shares the auth-state path)
unit:sandbox-listeners.test.ts + oracle: packages/conformance/observations/auth/auth-row-38-onidtokenchanged-fires-on-user-change.json (against blockingfun, fb-js-sdk 12.13.0: subscribe → 1 initial fire (null) → signInAnonymously → +1 (user₁) → signOut → +1 (null) → signInAnonymously → +1 (user₂ with fresh uid). Every identity transition produces exactly one fire, matching onAuthStateChanged)
onIdTokenChanged(auth, observer)Fires on EVERY sign-in, including a same-uid re-sign-in (no signOut first) — which mints a fresh token, so a subsequent getIdToken() returns a new string. Mirrors upstream notifyAuthListeners, which calls idTokenSubscription.next on every identity update (auth_impl.ts:716). onAuthStateChanged stays silent on the same-uid case (row 30a).
onIdTokenChanged(auth, observer)Fires on token refresh (getIdToken(true))
unit:sandbox-token-refresh.test.ts — was ⚠ (documented divergence); aligned to prod in commit — sandbox now mints a fresh token on forceRefresh and fires onIdTokenChanged (NOT onAuthStateChanged, since identity is unchanged). Oracle: packages/conformance/observations/auth/auth-onidtokenchanged-force-refresh.json defines the target shape (refreshFiredListener: true against blockingfun; subscribe → null fire → signInAnonymously → +1 → getIdToken(true) → +1 for a total of 3 fires).
onIdTokenChanged(auth, observer)Initial-fire semantics match onAuthStateChanged
unit:sandbox-listeners.test.ts + oracle: packages/conformance/observations/auth/auth-row-40-onidtokenchanged-matches-onauthstatechanged-initial-fire.json (against blockingfun, fb-js-sdk 12.13.0: subscribing both listeners in the same tick yields sync: {auth: 0, idToken: 0}microtask: {auth: 1, idToken: 1} → no further fires. Both listeners share the microtask-deferred initial-fire timing)

setPersistence(auth, persistence)

setPersistence(auth, persistence)Accepts inMemoryPersistence / browserSessionPersistence / browserLocalPersistence markers without throwing
setPersistence(auth, persistence)Returns Promise<void>
setPersistence(auth, persistence)Actually changes where the auth state is persisted
divergence: sandbox is a no-op. Prod respects the marker.
setPersistence(auth, persistence)An unrecognized persistence marker is rejected with auth/argument-error rather than silently coerced to LOCAL

signInWithPopup(auth, provider) / signInWithCredential(auth, credential)

signInWithPopup(auth, provider) / signInWithCredential(auth, credential)Returns the pre-staged UserCredential registered via sandbox.mockSignInResult(auth, …)
signInWithPopup(auth, provider) / signInWithCredential(auth, credential)Throws auth/no-mock-configured when no mock is pre-staged
signInWithPopup(auth, provider) / signInWithCredential(auth, credential)Mock is consumed after one read (subsequent call without a fresh stage throws again)
signInWithPopup(auth, provider) / signInWithCredential(auth, credential)After successful sign-in, currentUser becomes the mock's user, listeners fire
signInWithPopup(auth, provider) / signInWithCredential(auth, credential)The credential's rich User (email / displayName / isAnonymous) survives the transition — popup/redirect/credential/setUser do NOT clobber it down to the bare AuthState; cred.user === auth.currentUser (reference identity, matches upstream _updateCurrentUser(userCredential.user))
signInWithPopup(auth, provider) / signInWithCredential(auth, credential)Opens a popup window in prod
divergence: sandbox skips the popup; mock pre-stage replaces the popup result
signInWithPopup(auth, provider) / signInWithCredential(auth, credential)Cancels with auth/popup-closed-by-user when the user dismisses the popup (prod)
not modeled — would require the host to expose a "cancel" affordance on the mock
signInWithPopup(auth, provider) / signInWithCredential(auth, credential)Provider-flow sign-ins (popup / redirect / credential) record the flow's providerId on the identity in the user DB (upsert for unknown uids; append-if-missing for known ones) and reject disabled accounts with auth/user-disabled before any state change
unit:sandbox-user-admin.test.ts ("provider tracking", "disabled users") — provider recording is sandbox bookkeeping for listIdentities/listUsers; prod's auto-link semantics are narrower (same-email Google auto-link only) and not modeled

signInWithRedirect / getRedirectResult / resolver seam

signInWithRedirect / getRedirectResult` / resolver seamsignInWithRedirect(auth, provider, resolver?) resolves the flow (per-call resolver → injected → one-shot mock → auth/argument-error), signs the user in, and stashes the credential for one getRedirectResult
signInWithRedirect / getRedirectResult` / resolver seamgetRedirectResult(auth) returns the stashed credential once, then null (one-shot, matches prod)
signInWithRedirect / getRedirectResult` / resolver seamsandbox.setAuthFlowResolver(auth, resolver | null) installs / clears the popup/redirect resolver (the analog of browser getAuth wiring browserPopupRedirectResolver)
signInWithRedirect / getRedirectResult` / resolver seamsandbox.listIdentities(auth) snapshots every seeded/created identity for a host account-picker (sandbox-only — no firebase/auth equivalent)

Auth surface + error shape

Auth` surface + error shapeauth.signOut() method form works alongside the free signOut(auth) function (firebase/auth's Auth exposes both) (AUTH-GAP)
Auth` surface + error shapeSandbox auth errors are real FirebaseError instances (err instanceof FirebaseError) carrying the prod message wrapper Firebase: <message> (<auth/...>). — e.g. Firebase: Error (auth/invalid-email)., matching the oracle (AUTH-GAP)

Provider classes (GoogleAuthProvider, EmailAuthProvider, etc.)

Exports the same constructor signatures as firebase/auth for each provider
type-only smoke in unit:types.test.ts
Provider.credential(...) static factories produce AuthCredential-shaped objects
GoogleAuthProvider.providerId === 'google.com' (and per-provider analogs)
Custom scopes / params / language code
sandbox ignores; prod forwards

User methods

User` methodsuser.getIdToken() returns a stable opaque token in sandbox (sandbox-id-token-…)
User` methodsuser.getIdToken(true) (forceRefresh) returns a NEW token; subsequent getIdToken(false) returns the cached new token
unit:sandbox-token-refresh.test.ts — was ⚠ (documented divergence); aligned to prod in commit — sandbox now mints a fresh token on forceRefresh and fires onIdTokenChanged. Oracle: packages/conformance/observations/auth/auth-getidtoken-force-refresh.json defines the target shape (forceRefreshReturnedDifferentString: true, token1EqualsToken2: true against blockingfun — the refreshed token is cached, so a subsequent non-forced read returns it, not yet another fresh one). Sandbox tokens stay sandbox-id-token-<uid>-<serial>:<json> strings; prod's are real JWTs.
User` methodsuser.getIdTokenResult() returns claims
unit:sandbox-providers.test.ts (custom-claims path)
User` methodsuser.uid, user.email, user.displayName, user.isAnonymous reflect the source
playground:auth-anonymous, playground:auth-email-password
User` methodsuser.emailVerified — present on every sandbox-minted User (default false; sandbox has no verification flow). Prod passes the real value through (no longer stripped). The admin record (sandbox.listUsers) carries it too
User` methodsuser.photoURL / user.phoneNumber — present (sandbox default null; prod passes through, no longer stripped)
User` methodsuser.providerId (aggregate, 'firebase') + user.providerData: UserInfo[] — sandbox synthesizes one provider entry for non-anonymous users, empty for anonymous; prod passes the real array through (no longer stripped). The admin record carries the emulator-shaped providerUserInfo
User` methodsuser.metadata.creationTime / lastSignInTime
client User.metadata not exposed (AUTH-GAP); the admin record carries createdAt/lastLoginAt (ISO)
User` methodsIdTokenResult.signInProvider reflects the session's provider per flow ('anonymous' / 'password' / 'google.com' / …); claims include the reserved firebase.sign_in_provider (custom claims can't shadow it)
unit:sandbox-user-admin.test.ts ("IdTokenResult.signInProvider") — prod shape is documented SDK behavior; no oracle capture yet (Structured evidence: auth-signinprovider-per-flow)
User` methodsCustom-claims changes (sandbox.updateUser / re-seed) reach an active session on the next FORCED token refresh, not immediately — claims are read live from the user DB at mint time (prod's refresh-propagation story; AUTH-B10)
User` methodsuser.reload() / user.delete() / user.toJSON() / user.refreshToken / user.tenantId
not modeled by the sandbox; retained as an explicit unsupported behavior row rather than synthesized (AUTH-GAP)
updateProfile(user, {displayName, photoURL})updateProfile(user, {displayName, photoURL}) mutates the held user + stored record in place (null clears a field; omitted fields untouched) without an extra onAuthStateChanged fire. Profile survives signOutsignInWithEmailAndPassword.
unit:sandbox-update-profile.test.ts (in-place mutation, listUsers persist, null clear, no extra listener); re-sign-in persistence: unit:upstream-auth-probes.test.ts

beforeAuthStateChanged(auth, callback, onAbort?)

beforeAuthStateChanged(auth, callback, onAbort?)Registers a BLOCKING gate that runs before a real sign-in/sign-out transition commits. Callbacks run in registration order; a callback that throws (or returns a rejected promise) aborts the transition entirely: the pending signInWith…/signOut call rejects with auth/login-blocked, currentUser is unchanged, and onAuthStateChanged/onIdTokenChanged do NOT fire. Covers every sign-in path that exists in pyric/auth: signInAnonymously, signInWithEmailAndPassword, createUserWithEmailAndPassword, signInWithPopup, signInWithRedirect, signInWithCredential, signInWithCustomToken, and signOut. Modeled after the real @firebase/auth AuthMiddlewareQueue.runMiddleware (auth_impl.ts), read directly from the installed SDK source, not just its .d.ts.
unit:sandbox-before-auth-state-changed.test.ts — implementation cross-checked against @firebase/auth's AuthMiddlewareQueue source (registration-order queue, per-callback try/await, auth/login-blocked wrap). Custom-token allow/block: unit:upstream-auth-probes.test.ts. No live-oracle capture (no observable client-visible signal to probe beyond the documented+sourced contract already read).
beforeAuthStateChanged(auth, callback, onAbort?)onAbort semantics: when a callback throws, every onAbort registered by a callback that ALREADY SUCCEEDED in the same pass runs, in REVERSE registration order — matches upstream's rollback-stack (runMiddleware's onAbortStack). An onAbort that itself throws is swallowed so it can't mask the original block reason or skip the remaining rollbacks.
unit:sandbox-before-auth-state-changed.test.ts ("onAbort runs (in reverse order)…", "a callback whose own onAbort throws…")
beforeAuthStateChanged(auth, callback, onAbort?)Fires on BOTH directions: a real sign-in (nextUser non-null) and a real sign-out (nextUser === null) — a throwing callback blocks signOut too, leaving the previous user signed in. Matches upstream, where the same middleware queue gates _updateCurrentUser and signOut.
beforeAuthStateChanged(auth, callback, onAbort?)sandbox.setUser (the sandbox-only test driver) BYPASSES the gate entirely — it is a raw identity force with no prod analog (same bypass it already has for provider enforcement / signInProvider tracking), so no registered beforeAuthStateChanged callback runs and none can block it.
unit:sandbox-before-auth-state-changed.test.ts ("sandbox.setUser test driver bypasses the gate")
beforeAuthStateChanged(auth, callback, onAbort?)Served-worker path (SharedWorker-backed auth in @pyric/cli): the worker owns the shared user pool and commits transitions on its own side of the port, so a page-local beforeAuthStateChanged registration can't actually gate a worker-driven sign-in. Rather than silently accept a callback that could never run, registering THROWS immediately (auth/operation-not-supported-in-this-environment) — same defensive pattern as signInWithCredential over the worker.
packages/cli/src/serve/worker/client.ts (beforeAuthStateChanged throws makeUnsupported)

User-management and session exports

initializeAuth(app, deps?)Aliases getAuth(app) and returns the same stable Auth handle — an app that calls initializeAuth instead of getAuth gets an equivalent, working instance. The optional Dependencies arg (persistence / popupRedirectResolver) is accepted for signature parity but not applied (persistence is already a documented sandbox no-op). Repeated calls return the cached handle rather than throwing auth/already-initialized
unit:fruit-aliases.test.ts — returns the same instance as getAuth, with a live currentUser
deleteUser(user)Deletes the account from the user store AND signs the user out if they are current (fires onAuthStateChanged(null)), matching prod where deleting the signed-in user clears auth.currentUser. Real behavior: a subsequent signInWithEmailAndPassword for that identity throws auth/user-not-found; reload(user) after delete rejects with auth/user-token-expired
unit:fruit-aliases.test.ts — user removed from the store, sign-out fired, re-sign-in throws auth/user-not-found; post-delete reloadauth/user-token-expired: unit:upstream-auth-probes.test.ts
updateEmail(user, newEmail)Changes the stored email (via the same path as sandbox.updateUser, rejecting auth/email-already-in-use / auth/invalid-email) and mutates the held user in place, so the next sign-in resolves against the new email. Leniency vs prod: the sandbox does NOT enforce auth/requires-recent-login and is not routed through verifyBeforeUpdateEmail (which the real SDK requires when email-enumeration protection is on)
unit:fruit-aliases.test.ts — stored email changes; re-sign-in works with the new email, fails with the old
email really changes; no requires-recent-login / verifyBeforeUpdateEmail enforcement
updatePassword(user, newPassword)Sets the stored password (validated for strength). The sandbox DOES store and verify passwords, so this is a real mutation: a subsequent signInWithEmailAndPassword with the new password succeeds and the old one throws auth/wrong-password. Leniency vs prod: no auth/requires-recent-login enforcement
unit:fruit-aliases.test.ts — new password signs in, old password throws auth/wrong-password
password really changes + is verified; no requires-recent-login enforcement
reload(user)Re-reads the stored record into the user object in place, so a change made out of band (e.g. sandbox.updateUser) is reflected on the held reference — matching prod's server refresh. If the identity is absent from the store (including after deleteUser), rejects with auth/user-token-expired
unit:fruit-aliases.test.ts — an out-of-band sandbox.updateUser displayName change is visible on the held user after reload; missing-store / post-delete: unit:upstream-auth-probes.test.ts
updateCurrentUser(auth, user)Sets the sandbox's current user (pass null to sign out), firing onAuthStateChangedauth.currentUser reflects the passed user afterward
unit:fruit-aliases.test.tsauth.currentUser becomes the passed user; null signs out
useDeviceLanguage(auth)Accepted no-op — the sandbox has no device locale to read, so there is no language to set; accepted so init code that calls it compiles + runs
unit:fruit-aliases.test.ts — resolves/returns without error
no device locale in the sandbox
ActionCodeURL.parseLink(link) / parseActionCodeURL(link)Parses an out-of-band action link. The mode query param maps to a NORMALIZED operation (mode=resetPassword -> PASSWORD_RESET, mode=signIn -> EMAIL_SIGNIN), oobCode surfaces as code, lang as languageCode, and continueUrl comes out URL-DECODED. A link missing mode, missing oobCode, carrying an unknown mode, or that is not a URL at all parses to null — the parse NEVER throws. parseActionCodeURL and ActionCodeURL.parseLink agree.
ORACLE-BACKED and project-independent: this is a PURE client-side parse (no network, no project, no mailbox), so the sandbox owes prod an exact match and there is no room for a divergence. Oracle: auth-actioncodeurl-parse against firebase-js-sdk 12.13.0. Replayed in unit:oracle-conformance.test.ts. (Structured evidence: sandbox-email-link.test.ts)
isSignInWithEmailLink(auth, link)Pure predicate over the link string — no network. true only for a link whose mode is signIn AND which carries an oobCode; false for a password-reset link, for mode=signIn with no code, for garbage, and for the empty string. Never throws.
ORACLE-BACKED, project-independent (no server involved): auth-issigninwithemaillink-predicate captured all five cases against prod. Replayed in unit:oracle-conformance.test.ts. (Structured evidence: sandbox-email-link.test.ts)
applyActionCode(auth, code)Redeems an out-of-band code and performs its state change (VERIFY_EMAIL sets emailVerified, VERIFY_AND_CHANGE_EMAIL moves the account to the new address). Throws auth/invalid-action-code for a code the project never issued, for the empty string, and for a code already redeemed (single-use). A PASSWORD_RESET code is refused — confirmPasswordReset owns that one.
ORACLE-BACKED on the reject path: auth-action-code-invalid confirmed prod emits auth/invalid-action-code for both a bogus code and the empty string (this endpoint is NOT gated on the password provider, so it answered honestly). The APPLY path (a real code, redeemed) cannot be probed from a client — it needs a code from a real inbox — and is unit-backed end to end against the sandbox outbox in unit:sandbox-email-link.test.ts. (Structured evidence: oracle-conformance.test.ts)
sendSignInLinkToEmail(auth, email, settings)ActionCodeSettings validation, enforced CLIENT-side before any request leaves the process: a missing or unparseable url throws auth/invalid-continue-uri (NOT auth/missing-continue-uri, despite the constant's name), and handleCodeInApp other than true throws auth/argument-error. On success the sandbox mints a single-use code and posts the message to the outbox.
ORACLE-BACKED on both client-side arms (project-independent — prod threw before any network call): auth-sendsigninlinktoemail-settings-validation captured missingUrl: auth/invalid-continue-uri and handleCodeInAppFalse: auth/argument-error. Replayed in unit:oracle-conformance.test.ts; the send path is unit-backed in unit:sandbox-email-link.test.ts.
signInWithEmailLink(auth, email, link)Redeems the code in a sign-in link. A link carrying no oobCode throws auth/argument-error CLIENT-side (the SDK never reaches the server to ask about a code it cannot find in the link). A first-time sign-in for an address CREATES the account, getAdditionalUserInfo(cred).isNewUser is true, and the account arrives emailVerified: true — redeeming a code mailed to that address is proof of control. The code is single-use.
ORACLE-BACKED on the client-side reject (auth-signinwithemaillink-invalid-link: noOobCode: auth/argument-error). The REDEMPTION path could not be probed: the oracle project has email-link sign-in disabled, so the server arm answered auth/operation-not-allowed. That half is unit-backed end to end against the sandbox outbox (unit:sandbox-email-link.test.ts drives send -> read the outbox -> sign in). (Structured evidence: oracle-conformance.test.ts)
sendPasswordResetEmail(auth, email)For an address NO account owns, prod RESOLVES silently — it does not throw auth/user-not-found. Email Enumeration Protection is on by default and refusing to leak account existence is the point. The sandbox matches: it resolves and mails nothing. A malformed address still throws auth/invalid-email. For a real account, the sandbox mails a reset code; confirmPasswordReset redeems it and the new password signs in while the old one throws auth/wrong-password.
ORACLE-BACKED on the enumeration-protection behavior — the fact most likely to be got wrong: auth-sendpasswordresetemail-unknown-user captured resolvedForUnknownUser: true, unknownUserCode: null, malformedEmailCode: auth/invalid-email. A shim that threw auth/user-not-found here would hand agent code an account oracle production deliberately removed. Reset round trip unit-backed in unit:sandbox-email-link.test.ts. (Structured evidence: oracle-conformance.test.ts)
sendEmailVerification(user)Throws auth/missing-email for a user with no email on the account (an anonymous user). For a real account the mail goes out and NOTHING ELSE HAPPENS: user.emailVerified stays false. Verification happens when the code in the message is redeemed (applyActionCode), not when it is sent.
ORACLE-BACKED on the anonymous reject: auth-sendemailverification-shape captured anonymousUserCode: auth/missing-email against prod. The send-does-not-verify property is the one the whole flow turns on and is unit-backed (unit:sandbox-email-link.test.ts asserts emailVerified is still false after the send and true only after the code is applied) — it cannot be oracle-confirmed end to end because confirming it would require reading a real inbox. (Structured evidence: oracle-conformance.test.ts)
verifyBeforeUpdateEmail(user, newEmail)Mails a code to the NEW address and returns. The account's email does NOT change until that code is redeemed — the single guarantee separating this API from a bare updateEmail: the user must prove control of the new address before it becomes theirs. On redemption the account moves and the new address arrives emailVerified: true. checkActionCode on the code reports data.email = the new address and data.previousEmail = the old one.
UNIT-BACKED, not oracle-backed, and the reason is recorded rather than hidden: the probe (auth-verifybeforeupdateemail-shape) ran against the real project and came back auth/operation-not-allowed on every arm, because the oracle project has the Email/Password provider DISABLED and the probe could not even create the user whose email it would change. The capture is committed showing exactly that. Behavior is proven end to end against the sandbox in unit:sandbox-email-link.test.ts. (Structured evidence: oracle-conformance.test.ts)
sandbox.takeAuthMail(auth) / sandbox.setAuthMailResolver(auth, resolver)The sandbox IS the mail server. Every send posts a real message (operation, recipient, single-use code, and the full action link) to an outbox; sandbox.takeAuthMail reads it — the program's substitute for a human opening an inbox. The mailed link round-trips through the public isSignInWithEmailLink / parseActionCodeURL unchanged, and its code is the code the redeemer accepts, so the round trip production cannot complete without a human closes in-process. An installed AuthMailResolver is additionally notified per message; a resolver that THROWS does not fail the send that produced it.
unit:sandbox-email-link.test.ts — sandbox-only driver (no firebase/auth counterpart; this is the seam that makes the family testable at all).
checkActionCode(auth, code) / verifyPasswordResetCode(auth, code) / confirmPasswordReset(auth, code, newPassword)checkActionCode and verifyPasswordResetCode INSPECT a code without burning it — a check must not destroy the code the subsequent apply needs. confirmPasswordReset redeems it and sets the password, running the same strength check createUserWithEmailAndPassword runs; a weak new password throws auth/weak-password and does NOT burn the code (a typo must not destroy the user's one reset link). A code staged as expired throws auth/expired-action-code.
UNIT-BACKED, stated honestly: these three endpoints answered auth/operation-not-allowed in the oracle run (auth-action-code-invalid) because the oracle project's disabled password provider replies before the invalid-code contract can. That is a fact about the project's configuration, not about the API, so it is NOT asserted as evidence. The error codes used here are matched to the oracle-CAPTURED AuthErrorCodes map (INVALID_OOB_CODE = auth/invalid-action-code, EXPIRED_OOB_CODE = auth/expired-action-code — see auth#172). Behavior proven in unit:sandbox-email-link.test.ts.
sendSignInLinkToEmailauth/unauthorized-continue-uri (domain allowlist) · Prod rejects a continue URL whose domain is not on the project's authorized-domains list. The sandbox has NO domain allowlist and does not invent one: it accepts any parseable continue URL. A continue URL that would be rejected in production is accepted here.
The sandbox has no project config to hold an authorized-domains list, so there is nothing to check against; inventing an allowlist would mean inventing a policy the user never set. The probe attempted the server arm (auth-sendsigninlinktoemail-settings-validation, unauthorizedDomain) but the oracle project has email-link sign-in disabled, so it answered auth/operation-not-allowed rather than auth/unauthorized-continue-uri — the divergence is declared from the documented contract, not from a capture we do not have. (Structured evidence: sandbox-email-link.test.ts)
linkWithCredential(user, credential)The ANONYMOUS UPGRADE. Linking an email credential onto an anonymous account upgrades it IN PLACE: the uid is PRESERVED, isAnonymous flips to false, the email is set, and providerData gains the provider. Preserving the uid is what keeps the data the user created while anonymous theirs. Returns a UserCredential with operationType: 'link' and getAdditionalUserInfo().isNewUser === false (a link never creates an identity). The linked credential then works as a first-class signInWithEmailAndPassword.
UNIT-BACKED, not oracle-backed — stated plainly. The probe (auth-link-email-credential-to-anonymous) ran against the real project and returned linkCode: auth/operation-not-allowed: the oracle project has the Email/Password provider DISABLED, so no email credential can be minted there and the flow cannot be reached. The capture is committed showing exactly that rather than being dropped. Behavior proven in unit:sandbox-linking-reauth.test.ts, including the uid-preservation invariant.
linkWithCredentialconflict codes · auth/provider-already-linked when the account already carries the provider (one identity per provider). auth/email-already-in-use when the email credential belongs to a DIFFERENT account — an address can back only one identity, so the link cannot be granted without stealing it.
UNIT-BACKED. The probe (auth-link-conflicts) ran and every arm returned auth/operation-not-allowed (same disabled Email/Password provider on the oracle project), so the conflict codes could not be observed against prod. They are matched instead to the oracle-CAPTURED AuthErrorCodes map (PROVIDER_ALREADY_LINKED = auth/provider-already-linked, CREDENTIAL_ALREADY_IN_USE = auth/credential-already-in-use — auth#172) and proven in unit:sandbox-linking-reauth.test.ts. NOTE: for an EMAIL credential the sandbox emits auth/email-already-in-use, the code prod uses for an address collision; credential-already-in-use remains the OAuth-credential case.
unlink(user, providerId)Detaches a provider and returns the updated user with a SHRUNKEN providerData. Unlinking a provider that was never linked throws auth/no-such-provider. Unlinking 'password' takes the password with it, so signInWithEmailAndPassword for that account stops working. Unlinking the LAST provider does NOT re-anonymize the account — isAnonymous describes how an identity was born, not what it currently carries.
ORACLE-BACKED on the reject path — the ONE linking fact the oracle could reach, because it needs no email credential: auth-unlink-provider captured noSuchProviderCode: auth/no-such-provider against prod on an anonymous user. Replayed in unit:oracle-conformance.test.ts. The detach path is unit-backed (unit:sandbox-linking-reauth.test.ts).
linkWithPopup(user, provider, resolver?) / linkWithRedirect(...)Route through the SAME AuthFlowResolver seam as signInWithPopup, with authType: 'link' on the request so a host UI can present 'link your Google account' rather than 'sign in'. The resolved credential names the provider to attach; the sandbox performs the attach and the uid is preserved. A disabled provider throws auth/operation-not-allowed AHEAD of the resolver check — a code deliberately distinct from auth/argument-error, which keeps meaning 'enabled, but no resolver/mock wired'.
unit:sandbox-linking-reauth.test.ts — asserts the resolver sees authType: 'link', the uid is preserved, and the two error codes stay distinct. The OAuth arm cannot be oracle-probed at all (it needs a real IdP popup and a human), which is precisely why it goes through the resolver seam.

reauthenticateWithCredential / reauthenticateWithPopup / reauthenticateWithRedirect — re-authentication

reauthenticateWithCredential(user, credential)Really re-verifies: an email credential is checked against the stored password exactly as signInWithEmailAndPassword checks it. A wrong password throws auth/wrong-password. A credential belonging to a DIFFERENT account throws auth/user-mismatch — checked BEFORE the password compare, so it cannot leak whether the other account's password was right. On success a fresh ID token is minted (a new authTime), and the returned UserCredential carries operationType: 'reauthenticate'.
UNIT-BACKED, and the reason it is not oracle-backed is recorded rather than glossed: the probe (auth-reauthenticate-with-credential) ran against the real project and could not even create the two accounts it needs — setupCode: auth/operation-not-allowed, because the oracle project has the Email/Password provider DISABLED. The capture is committed showing that. auth/user-mismatch is matched to the oracle-captured AuthErrorCodes map (auth#172). Behavior proven in unit:sandbox-linking-reauth.test.ts.
reauthenticate*auth/requires-recent-login is NOT enforced · In production the POINT of re-authentication is the auth/requires-recent-login gate: updateEmail / updatePassword / deleteUser refuse to run on a session whose sign-in is older than a few minutes, and re-auth is how you clear it. The sandbox does NOT enforce that gate — those three mutations already run on a session of any age (a pre-existing documented divergence), so there is no gate here for re-auth to clear. What re-auth DOES do here is real but narrower: it genuinely re-verifies the credential, mints a fresh token, and returns operationType: 'reauthenticate'. Code that calls it runs unchanged against prod, where it also clears the gate.
Declared divergence, not a bug: inventing a recent-login gate would break every existing sandbox flow (which never re-authenticates) while proving nothing. unit:sandbox-linking-reauth.test.ts pins the behavior that IS provided (real credential re-verification + a fresh token).
reauthenticateWithPopup(user, provider, resolver?) / reauthenticateWithRedirect(...)Route through the shared resolver seam with authType: 'reauth'. The identity the resolver produces MUST be the user being re-authenticated — a resolver that hands back a different uid throws auth/user-mismatch. That check is the entire security content of the flow; without it 're-authentication' would accept anyone.
unit:sandbox-linking-reauth.test.ts — asserts the resolver sees authType: 'reauth' and that an impostor identity is rejected with auth/user-mismatch.

Constants, credentials, and inert config tokens

getAdditionalUserInfo(userCredential)Returns { isNewUser, profile, providerId }. For a fresh anonymous sign-in prod reports { isNewUser: true, providerId: null, profile: {} }providerId is NULL, not 'anonymous', because anonymous is not a federated provider. isNewUser is true for createUserWithEmailAndPassword, a first-time email-link sign-in, and a custom-token sign-in that minted the account; false for a returning signInWithEmailAndPassword and for every link / reauthenticate.
ORACLE-BACKED on the anonymous shape: auth-additional-user-info-shape captured {isNewUser: true, providerId: null, profile: {}} against prod. Replayed in unit:oracle-conformance.test.ts. Email create/sign-in + custom-token mint/return isNewUser matrix (providerId null per AUTH-B2): unit:upstream-auth-probes.test.ts. Email-link + link/reauth arms: unit:sandbox-email-link.test.ts, unit:sandbox-linking-reauth.test.ts.
ProviderId / SignInMethod / OperationType / ActionCodeOperation / AuthErrorCodesThe constant maps, value for value. OperationType.SIGN_IN === 'signIn', SignInMethod.EMAIL_LINK === 'emailLink', ProviderId.GOOGLE === 'google.com', ActionCodeOperation.PASSWORD_RESET === 'PASSWORD_RESET', and the 106-entry AuthErrorCodes map (INVALID_OOB_CODE === 'auth/invalid-action-code', PROVIDER_ALREADY_LINKED === 'auth/provider-already-linked', NO_SUCH_PROVIDER === 'auth/no-such-provider', USER_MISMATCH === 'auth/user-mismatch', …).
ORACLE-BACKED, snapshotted straight from the shipped SDK: auth-mechanical-surface-constants against firebase-js-sdk 12.13.0, replayed value-for-value in unit:oracle-conformance.test.ts. This matters more than it looks: consumer code COMPARES against these constants, so a mirror that got a string wrong would turn every such comparison into a silent false — a worse failure than the export simply being absent, because it typechecks and runs. NOTE: the capture's persistenceTypes block is deliberately NOT asserted — the harness runs under Node, where firebase/auth stubs the browser-only persistence tokens to type: 'NONE' (it reports 'NONE' even for browserLocalPersistence, which is unambiguously 'LOCAL'); asserting it would be asserting a harness artifact. See auth#178.
signInWithCustomToken(auth, customToken)Throws auth/invalid-custom-token for a malformed token and for the empty string. The sandbox accepts a token in the two shapes it can honestly read: a JSON (optionally base64url) {uid, claims} payload — exactly what admin.auth().createCustomToken signs, so the pyric-admin mint and this redeem compose — or a real three-part JWT whose payload segment carries uid/sub. The SIGNATURE IS NOT VERIFIED: the sandbox has no service-account key. The identity is created if it does not exist, and the credential carries providerId: null (custom-token sign-in is not a federated provider).
ORACLE-BACKED on the reject path: auth-signinwithcustomtoken-invalid captured auth/invalid-custom-token for both a malformed token and the empty string, replayed in unit:oracle-conformance.test.ts. Diverged on the ACCEPT path, declared rather than hidden: prod verifies an RS256 signature against the project's service-account key and the sandbox has no key, so it reads the token's claims WITHOUT verifying them. A forged token that prod would reject is accepted here. The happy path cannot be oracle-probed from a Web SDK client at all (it needs an Admin-SDK-signed JWT).
validatePassword(auth, password)Returns a PasswordValidationStatus against the project's password policy, WITHOUT attempting a sign-up (so a UI can show live strength feedback). The sandbox's policy is minPasswordLength: 6, maxPasswordLength: 4096, enforcementState: 'ENFORCE', with every character-class requirement UNSET — so a password this function calls valid is exactly one createUserWithEmailAndPassword will accept. The character-class fields are undefined, not false: upstream distinguishes 'not required' from 'required and unmet', and reporting false would claim the password failed a rule the project never had.
ORACLE-BACKED: auth-validatepassword-status-shape captured prod's live policy (minPasswordLength 6, maxPasswordLength 4096, ENFORCE, character classes unset) and the status shape for a weak and a strong password. Replayed in unit:oracle-conformance.test.ts. The 6-char minimum agrees with the separately oracle-pinned auth/weak-password threshold on the create path, so the two surfaces draw the same line here exactly as they do in prod.
fetchSignInMethodsForEmail(auth, email)Not implemented; disposition auth.email-enumeration owns the reviewed availability and rationale.
disposition:auth.email-enumeration; the auth-fetchsigninmethodsforemail-deprecated capture is retained but inconclusive because the oracle project's Email/Password provider is disabled.
inMemoryPersistence / browserSessionPersistence / browserLocalPersistence / indexedDBLocalPersistence / browserCookiePersistence / browserPopupRedirectResolver / debugErrorMap / prodErrorMapInert configuration tokens, accepted so the idiomatic initializeAuth(app, { persistence: indexedDBLocalPersistence }) and setPersistence(auth, browserCookiePersistence) compile, run, and behave identically. The .type discriminant matches upstream exactly (NONE / SESSION / LOCAL / LOCAL / COOKIE) because consumer code branches on it. browserPopupRedirectResolver is accepted and ignored — the sandbox has its own first-class pluggable equivalent (sandbox.setAuthFlowResolver). prodErrorMap is accepted and DELIBERATELY not honored: installing it upstream strips error messages, and doing that in a sandbox whose purpose is to tell a developer what went wrong would be actively hostile.
In an in-memory sandbox the persistence CHOICE has no observable consequence, so accepting the token and recording the mode is the honest behavior — the same inert-token pattern pyric/firestore uses for its cache-factory tokens. unit:types.test.ts + unit:sandbox-email-link.test.ts cover the exports. Oracle caveat recorded on auth#172: the persistenceTypes block of auth-mechanical-surface-constants reports NONE for every token including browserLocalPersistence — a Node-build stub artifact, not the browser contract, so the .type values here follow the documented Persistence.type union instead.
revokeAccessToken(auth, token)Accepted no-op. In production this reaches OUTSIDE Firebase entirely — it tells the identity provider (in practice Apple) to revoke an OAuth access token, which is a call landing on Apple's servers. There is no external IdP behind a sandbox sign-in, so there is no token out there to revoke and nothing this call could truthfully do. It resolves (so the account-deletion flow Apple requires an app to ship runs end to end against the sandbox) and changes no sandbox state (because claiming otherwise would be a lie).
unit:types.test.ts — the export resolves. Deliberately not oracle-probed: a successful probe would revoke a real token at a real IdP, which is a side effect on someone else's system that a conformance run has no business causing.
AuthCredential / EmailAuthCredential / OAuthCredentialReal classes, not markers. EmailAuthProvider.credential(email, password) returns an EmailAuthCredential that CARRIES THE SECRET — which is what makes linkWithCredential, reauthenticateWithCredential, and signInWithCredential decidable in the sandbox with no resolver and no mock (the backend already stores and verifies passwords). credentialWithLink carries the link instead, and signInMethod discriminates ('password' vs 'emailLink'). The backing secret is non-enumerable, so a spread or Object.keys walk does not pick it up; toJSON() DOES carry it, matching upstream (whose fromJSON needs it to round-trip). OAuthCredential carries the IdP tokens, which the sandbox cannot verify — those flows keep going through the resolver seam.
unit:sandbox-linking-reauth.test.ts — pins the secret-carrying behavior, the enumerable/toJSON split (so nobody 'hardens' it into a divergence later), and that a real email credential now signs in via signInWithCredential instead of throwing the sandbox-only auth/no-mock-configured it used to.
TwitterAuthProvider / SAMLAuthProviderProvider marker classes. TwitterAuthProvider.credential(token, secret) takes a token AND a secret — Twitter is the one OAuth 1.0a provider in the set, where the OAuth 2.0 providers take a single access token. SAMLAuthProvider's constructor ENFORCES the saml. providerId prefix (throwing auth/argument-error otherwise), because that id is what routes an assertion to the right configured IdP and a typo there would silently target nothing. SAML has no client-constructible credential, so the class has no credential() factory.
unit:types.test.ts — construction, PROVIDER_ID values, and the SAML prefix guard.
User.providerDataOne entry PER LINKED PROVIDER, read from the identity's stored record. Previously the sandbox synthesized a single {providerId: 'password'} entry for EVERY non-anonymous user, so a Google popup sign-in reported its provider as 'password' and a linked account could never show more than one. Consumer code branches on this array (that is what it is for — 'is this account linked to Google?'), so the synthesized version was actively misleading. Empty for anonymous users; after unlink of the last provider it is genuinely empty (it does not resurrect the removed provider).
Was a BUG until this climb, and worth naming as one: the array was fabricated, not read. Now fixed and locked. unit:sandbox-linking-reauth.test.ts now pins that a Google link surfaces google.com in providerData, that unlink shrinks it, and that unlinking the last provider leaves it empty rather than falling back to a synthesized 'password' entry.

Current gaps

Documented divergences

Known differences between Pyric and production Firebase. Each remains tracked as a non-conforming row.

signInAnonymously(auth)Auto-generates a uid for fresh sign-ins (sandbox format: anonymous-{N})
unit:sandbox-anonymous.test.ts — prod uids are 28-char base64-ish; sandbox uses a readable counter for debuggability
signInAnonymously(auth)Anonymous users persist across page reload via configured Persistence (prod only — sandbox has no persistence layer)
divergence: sandbox memory only; within one tab the user persists, across reload they don't
signInWithEmailAndPassword(auth, email, password)An EMPTY password throws auth/missing-password (message "A non-empty password must be provided"), fired before the user-DB lookup so it can't be used to enumerate seeded emails. Upstream maps the MISSING_PASSWORD server error (core/errors.ts:92,282,563). ⚠ best-known semantics — message text not yet captured against a live project (STOP-flagged for an oracle pass; the .code is the load-bearing part).
signOut(auth)Clears the active session's persistence in prod; sandbox has no persistence
divergence: same memory-only constraint as the anonymous persistence row
setPersistence(auth, persistence)Actually changes where the auth state is persisted
divergence: sandbox is a no-op. Prod respects the marker.
signInWithPopup(auth, provider)` / `signInWithCredential(auth, credential)Opens a popup window in prod
divergence: sandbox skips the popup; mock pre-stage replaces the popup result
beforeAuthStateChanged(auth, callback, onAbort?)sandbox.setUser (the sandbox-only test driver) BYPASSES the gate entirely — it is a raw identity force with no prod analog (same bypass it already has for provider enforcement / signInProvider tracking), so no registered beforeAuthStateChanged callback runs and none can block it.
unit:sandbox-before-auth-state-changed.test.ts ("sandbox.setUser test driver bypasses the gate")
beforeAuthStateChanged(auth, callback, onAbort?)Served-worker path (SharedWorker-backed auth in @pyric/cli): the worker owns the shared user pool and commits transitions on its own side of the port, so a page-local beforeAuthStateChanged registration can't actually gate a worker-driven sign-in. Rather than silently accept a callback that could never run, registering THROWS immediately (auth/operation-not-supported-in-this-environment) — same defensive pattern as signInWithCredential over the worker.
packages/cli/src/serve/worker/client.ts (beforeAuthStateChanged throws makeUnsupported)
updateEmail(user, newEmail)Changes the stored email (via the same path as sandbox.updateUser, rejecting auth/email-already-in-use / auth/invalid-email) and mutates the held user in place, so the next sign-in resolves against the new email. Leniency vs prod: the sandbox does NOT enforce auth/requires-recent-login and is not routed through verifyBeforeUpdateEmail (which the real SDK requires when email-enumeration protection is on)
unit:fruit-aliases.test.ts — stored email changes; re-sign-in works with the new email, fails with the old
updatePassword(user, newPassword)Sets the stored password (validated for strength). The sandbox DOES store and verify passwords, so this is a real mutation: a subsequent signInWithEmailAndPassword with the new password succeeds and the old one throws auth/wrong-password. Leniency vs prod: no auth/requires-recent-login enforcement
unit:fruit-aliases.test.ts — new password signs in, old password throws auth/wrong-password
useDeviceLanguage(auth)Accepted no-op — the sandbox has no device locale to read, so there is no language to set; accepted so init code that calls it compiles + runs
unit:fruit-aliases.test.ts — resolves/returns without error
sendSignInLinkToEmailProd rejects a continue URL whose domain is not on the project's authorized-domains list. The sandbox has NO domain allowlist and does not invent one: it accepts any parseable continue URL. A continue URL that would be rejected in production is accepted here.
The sandbox has no project config to hold an authorized-domains list, so there is nothing to check against; inventing an allowlist would mean inventing a policy the user never set. The probe attempted the server arm (auth-sendsigninlinktoemail-settings-validation, unauthorizedDomain) but the oracle project has email-link sign-in disabled, so it answered auth/operation-not-allowed rather than auth/unauthorized-continue-uri — the divergence is declared from the documented contract, not from a capture we do not have. (Structured evidence: sandbox-email-link.test.ts)
reauthenticate*In production the POINT of re-authentication is the auth/requires-recent-login gate: updateEmail / updatePassword / deleteUser refuse to run on a session whose sign-in is older than a few minutes, and re-auth is how you clear it. The sandbox does NOT enforce that gate — those three mutations already run on a session of any age (a pre-existing documented divergence), so there is no gate here for re-auth to clear. What re-auth DOES do here is real but narrower: it genuinely re-verifies the credential, mints a fresh token, and returns operationType: 'reauthenticate'. Code that calls it runs unchanged against prod, where it also clears the gate.
Declared divergence, not a bug: inventing a recent-login gate would break every existing sandbox flow (which never re-authenticates) while proving nothing. unit:sandbox-linking-reauth.test.ts pins the behavior that IS provided (real credential re-verification + a fresh token).
signInWithCustomToken(auth, customToken)Throws auth/invalid-custom-token for a malformed token and for the empty string. The sandbox accepts a token in the two shapes it can honestly read: a JSON (optionally base64url) {uid, claims} payload — exactly what admin.auth().createCustomToken signs, so the pyric-admin mint and this redeem compose — or a real three-part JWT whose payload segment carries uid/sub. The SIGNATURE IS NOT VERIFIED: the sandbox has no service-account key. The identity is created if it does not exist, and the credential carries providerId: null (custom-token sign-in is not a federated provider).
ORACLE-BACKED on the reject path: auth-signinwithcustomtoken-invalid captured auth/invalid-custom-token for both a malformed token and the empty string, replayed in unit:oracle-conformance.test.ts. Diverged on the ACCEPT path, declared rather than hidden: prod verifies an RS256 signature against the project's service-account key and the sandbox has no key, so it reads the token's claims WITHOUT verifying them. A forged token that prod would reject is accepted here. The happy path cannot be oracle-probed from a Web SDK client at all (it needs an Admin-SDK-signed JWT).
inMemoryPersistence / browserSessionPersistence / browserLocalPersistence / indexedDBLocalPersistence / browserCookiePersistence / browserPopupRedirectResolver / debugErrorMap / prodErrorMapInert configuration tokens, accepted so the idiomatic initializeAuth(app, { persistence: indexedDBLocalPersistence }) and setPersistence(auth, browserCookiePersistence) compile, run, and behave identically. The .type discriminant matches upstream exactly (NONE / SESSION / LOCAL / LOCAL / COOKIE) because consumer code branches on it. browserPopupRedirectResolver is accepted and ignored — the sandbox has its own first-class pluggable equivalent (sandbox.setAuthFlowResolver). prodErrorMap is accepted and DELIBERATELY not honored: installing it upstream strips error messages, and doing that in a sandbox whose purpose is to tell a developer what went wrong would be actively hostile.
In an in-memory sandbox the persistence CHOICE has no observable consequence, so accepting the token and recording the mode is the honest behavior — the same inert-token pattern pyric/firestore uses for its cache-factory tokens. unit:types.test.ts + unit:sandbox-email-link.test.ts cover the exports. Oracle caveat recorded on auth#172: the persistenceTypes block of auth-mechanical-surface-constants reports NONE for every token including browserLocalPersistence — a Node-build stub artifact, not the browser contract, so the .type values here follow the documented Persistence.type union instead.
revokeAccessToken(auth, token)Accepted no-op. In production this reaches OUTSIDE Firebase entirely — it tells the identity provider (in practice Apple) to revoke an OAuth access token, which is a call landing on Apple's servers. There is no external IdP behind a sandbox sign-in, so there is no token out there to revoke and nothing this call could truthfully do. It resolves (so the account-deletion flow Apple requires an app to ship runs end to end against the sandbox) and changes no sandbox state (because claiming otherwise would be a lie).
unit:types.test.ts — the export resolves. Deliberately not oracle-probed: a successful probe would revoke a real token at a real IdP, which is a side effect on someone else's system that a conformance run has no business causing.

Not implemented yet

Tracked behavior that is not implemented in the current contract.

signInWithPopup(auth, provider)` / `signInWithCredential(auth, credential)Cancels with auth/popup-closed-by-user when the user dismisses the popup (prod)
not modeled — would require the host to expose a "cancel" affordance on the mock
Custom scopes / params / language code
sandbox ignores; prod forwards
User` methodsuser.metadata.creationTime / lastSignInTime
client User.metadata not exposed (AUTH-GAP); the admin record carries createdAt/lastLoginAt (ISO)
User` methodsuser.reload() / user.delete() / user.toJSON() / user.refreshToken / user.tenantId
not modeled by the sandbox; retained as an explicit unsupported behavior row rather than synthesized (AUTH-GAP)
fetchSignInMethodsForEmail(auth, email)Not implemented; disposition auth.email-enumeration owns the reviewed availability and rationale.
disposition:auth.email-enumeration; the auth-fetchsigninmethodsforemail-deprecated capture is retained but inconclusive because the oracle project's Email/Password provider is disabled.

Unverified

Tracked behavior whose available evidence does not yet establish the production result.

getAuth(target)Without sandbox package swapping, canonical firebase/auth imports remain Firebase and never enter this mirror
Direct mirror rejection is locked by unit:package-resolution.test.ts; an unswapped production-resolution observation is still needed

Reviewed public-runtime gaps

auth.mfa-phone-recaptchaMFA / phone / reCAPTCHA is deferred, not out of scope — reCAPTCHA/SMS are external infrastructure Pyric can mock with the OAuth resolver pattern, and TOTP is pure algorithm work.
FactorId PhoneAuthCredential PhoneAuthProvider PhoneMultiFactorGenerator RecaptchaVerifier TotpMultiFactorGenerator TotpSecret getMultiFactorResolver initializeRecaptchaConfig linkWithPhoneNumber multiFactor reauthenticateWithPhoneNumber signInWithPhoneNumber
registry:auth#180
auth.email-enumerationDeprecated upstream as a security retraction, not a cosmetic one: the shipped @firebase/auth declaration states it "returns an empty list when Email Enumeration Protection is enabled, irrespective of the number of authentication methods available", and that "migrating off of this method is recommended as a security best-practice". Enumeration protection is on by default, so against a modern project the production function always returns []. A mirror that reproduced the pre-deprecation behavior would be MORE capable than prod and would silently mislead agent code that branches on the result; a mirror that reproduced the post-deprecation behavior would be a no-op. Same criterion applied to Imagen in firebase/ai — an API whose production counterpart is retiring is out of scope, because mirroring it freezes dead behavior.
fetchSignInMethodsForEmail
registry:auth#175, upstream:firebase/auth
auth.phone-mutationPhone number mutation is deferred alongside the rest of the phone-auth family — buildable via a mocked SMS/verification-code resolver, the same shape as the OAuth resolver.
updatePhoneNumber
registry:auth#181