Skip to main content

Authentication & sessions

Status: skeleton. The auth shape (bloc, guards, repository seam, RLS schema) is built and correct; the real Supabase wiring is deferredproductionConfiguration throws UnimplementedError, and the only live adapter is in-memory. Treat this page as "what exists + what's undefined".

The data path

Built

  • AuthRepository (abstract seam) + InMemoryAuthRepository (skeleton: email/password + a broadcast authStateChanges() stream) — app/lib/outside/repositories/auth/.
  • AuthBloc + AuthState(status: unknown | authenticated | unauthenticated, user, errorMessage)app/lib/inside/blocs/auth/. It reacts to the repo's auth stream (AuthUserChanged).
  • GuardsAuthenticatedGuard (household == null → SetupRoute; seenIntro → intro/welcome branch) + UnauthenticatedGuard (authenticated → HomeRoute) — app/lib/inside/routes/guards/. Welcome/Intro are intentionally unguarded.
  • SignUpBloc (username + email + password, match validation) + ForgotPasswordBloc (calls requestPasswordReset, currently no-op).
  • RLS schemahouseholds + household_members policies via member_household_ids() / parental_household_ids() SECURITY DEFINER functions (infra/supabase/migrations, migration 01). Invite columns (07), terms/country (08).

Gaps / under-defined

  • Real Supabase auth is deferredproductionConfiguration throws UnimplementedError for createClient and createAuthRepository; the app can't deploy against the cloud yet.
  • Username ↔ identity mapping unspecified — the sign-up username is captured in the UI/bloc but discarded by InMemoryAuthRepository; no profiles table/column or synthetic-email strategy exists.
  • RLS is unreachable at runtime for auth flows — the cloud data adapter (SupabaseStorageAdapter) is now built (SP3) and does reach PostgREST under RLS, but it requires a valid Supabase JWT from the auth layer. productionConfiguration still throws UnimplementedError for createAuthRepository, so authenticated Supabase requests cannot be issued until the real auth wiring lands.
  • Co-parent invites are modelled, not wired — the HouseholdMember invite fields + MemberAccess expiry check exist, but there's no accept-invite bloc/route/deep-link, and the send-invite Edge Function is a stub. Invite expiry isn't RLS-enforced.
  • No terms-acceptance guardkCurrentTermsVersion is gated in HouseholdService but no route/page blocks an authenticated user who never accepted.
  • AuthChangeEffect.changes() is a permanently-empty stream — live auth runs off AuthRepository.authStateChanges(); the intended migration to the effect stream is undocumented.

Admin-direct-create adult path (app-wired; cloud deploy pending)

This path lets an admin add a co-parent or other adult to the household without an email round-trip. The adult receives a one-time temp password and is forced to rotate it on first login (the "LOCKED D1/D2 credential model").

Data path

Edge Function security model (code-complete, deploy pending)

infra/supabase/functions/admin-create-adult/ is reviewed and committed but not yet deployed to Supabase. Its gates, in order:

  1. verify_jwt ON — the Supabase gateway rejects any call without a valid caller JWT before the handler runs.
  2. Caller-authz dual-binding — the caller's JWT is verified server-side via admin.auth.getUser; the caller must also be an active parent/co_parent member of the target household AND hold roles ⊇ ['admin'] OR owner = true. A query error or missing row is fail-closed (treated as rejection).
  3. isAdult firewallkind must be one of parent | co_parent | other_adult; child or any unknown kind is rejected before any auth lookup so this path can never be a backdoor into the COPPA-gated child flow.
  4. Credential model — generates a 192-bit URL-safe base64 temp password, calls createUser({ email_confirm: true, user_metadata: { must_change_password: true } }), returns the temp password once in the response (never logged).
  5. Partial-failure rollback — if the household_members insert fails after the GoTrue user was minted, the handler deletes the just-created auth user so no orphan credential lingers. A rollback failure is logged with the auth_user_id UUID (not PII, not a secret) for operator reconciliation.
  6. Audit — appends an invite_events row with kind = 'created' (LOCKED D4); the migration for this row (invite_events 'created' kind) is committed alongside the function.

Auth-seam additions (app-wired, regression-tested)

These are live in the app build:

  • AuthUser.mustChangePassword (bool, default false) — added to the presentation-layer auth model. Populated from GoTrue user_metadata when cloud auth is wired; in-memory skeleton defaults to false (normal users) and clears it on updatePassword.
  • AuthAccount.mustChangePassword — the matching field on the SDK-layer ClientAuth account type (packages/client_sdk/lib/src/client/client_auth.dart). Sourced from GoTrue user_metadata.must_change_password; cleared atomically by ClientAuth.updatePassword in a single GoTrue updateUser call.
  • AuthRepository.updatePassword({required String newPassword}) — new abstract method on the auth seam. Delegates to GoTrue updateUser (cloud) or clears the flag locally (in-memory skeleton). Both paths emit a new AuthUser onto authStateChanges() so the guard can react without a page reload.

MustChangePasswordGuard (app-wired, un-bypassable, regression-tested)

app/lib/inside/routes/guards/must_change_password_guard.dart — an AutoRouteGuard that runs on every authenticated route after AuthenticatedGuard:

  • If AuthRepository.currentUser?.mustChangePassword == true and the target route is not SetPasswordRoute, the guard pushes SetPasswordRoute and aborts the original navigation.
  • SetPasswordRoute is always allowed through (loop-prevention).
  • A null user or mustChangePassword == false passes immediately (normal users are never trapped; signed-out users are caught by AuthenticatedGuard first).
  • Wired in router.dart; covered by must_change_password_guard_test.dart and the router gate coverage test (router_gate_coverage_test.dart).

End-to-end completeness: the app-side wiring (guard + seam + admin UI) is complete and tested. The full D1/D2 credential flow (admin creates adult → app blocks until password rotated) becomes end-to-end functional once the admin-create-adult Edge Function is deployed to the Supabase project.

See the auth/onboarding branding spec for the onboarding flow and the hosting & auth decision.