Swappable Auth Providers — Design
Status: Implemented · Branch: swappable-auth-providers
See As built at the end for the concrete file layout, operator commands, and where the implementation differs from the original proposal.
Goal
Make authentication pluggable across the Flask API backend and the Next.js UI, with three interchangeable providers selected by configuration:
auth0— the current Auth0 OIDC/JWT implementation (unchanged behavior when selected).password_file— auser:passwordfile administered by a Python CLI, mounted into the API container and re-read on every auth check so new/changed users take effect without restarting the web stack.none— "desktop mode". Authentication is faux: every request is the fixed userlocal. All user-aware business logic keeps working unchanged.
The provider is chosen by a single config value and activated at process start; the UI discovers the active provider at runtime so a single build serves all three modes.
Current state (what we're abstracting)
Backend (api/, Flask)
api/utils/auth.pyexposes three decorators used across ~23 endpoints:requires_auth(required_permission=None, check_permissions=[...])requires_enrollment—requires_authwithAUTH0_REQUIRED_PERMISSIONoptional_enrollment— authenticate-if-present, else anonymous (request.user = {})- Each decorator validates a
BearerJWT against Auth0 JWKS (verify_token) and setsrequest.user = <payload dict>plusset_userid(payload["sub"])for logging. - Handlers read
request.user.get("sub")andrequest.user.get("permissions", []).check_permissionsflags are stashed viasetattr(request, perm.value, bool). PermissionTypeenum:enroll:autodiscovery_admin,enroll:higher_upload_limit,enroll:ai1_datasets,enroll:asta_integration./api/user/mefetches the full profile from Auth0/userinfo.packages/autodiscovery_jobs/src/.../auth0.pydoes Management-API lookups (email by id) for offline scripts (send_completion_emails.py,export_users_csv.py).- Config is plain
os.environ.get(...):AUTH0_DOMAIN,AUTH0_AUDIENCE,AUTH0_REQUIRED_PERMISSION,AUTH0_MGMT_*,DEV_MASQUERADE_USER.
Frontend (ui/, Next.js App Router)
ui/src/app/auth/Auth0Client.ts—@auth0/auth0-spa-jsclient fromNEXT_PUBLIC_AUTH0_*.ui/src/app/contexts/Auth0Context.tsx—useAuth0()context:isAuthenticated,isLoading,user,loginWithRedirect,logout,getAccessToken,hasRequiredPermission,canExploreWithAsta,authError.ui/src/app/api/BaseApi.ts— importsauth0Clientdirectly; attachesAuthorization: Bearer <token>; redirects to login on 401.- Permissions are decoded client-side from the JWT in two places (
Auth0Context.tsx,metrics/layout.tsx). - Route gating is client-side via
isAuthenticated/ permission flags.
Backend design
1. Normalized identity
# api/utils/auth/models.py
@dataclass(frozen=True)
class AuthenticatedUser:
sub: str # stable id, namespaced per provider
permissions: list[str] # e.g. ["enroll:autodiscovery_admin"]
email: str | None = None
name: str | None = None
picture: str | None = None
email_verified: bool | None = None
def to_request_user(self) -> dict:
"""Back-compat dict shape that handlers already read via request.user.get(...)."""
return {
"sub": self.sub, "permissions": self.permissions, "email": self.email,
"name": self.name, "picture": self.picture,
"email_verified": self.email_verified,
}
request.user stays a dict with the same keys, so none of the ~23 handler call
sites change. sub is namespaced per provider to keep GCS paths (users/{sub}/...)
collision-free and recognizable:
- auth0: auth0|..., google-oauth2|... (unchanged)
- password_file: file|<username>
- none: local
2. Provider interface
# api/utils/auth/base.py
class NoCredentialsError(Exception): ... # nothing presented
class InvalidCredentialsError(Exception): ... # presented but bad -> 401 w/ message
class AuthProvider(ABC):
name: str
@abstractmethod
def authenticate(self, request) -> AuthenticatedUser:
"""Validate the request's credentials. Raise NoCredentialsError if none,
InvalidCredentialsError if present-but-invalid."""
def user_profile(self, request, user: AuthenticatedUser) -> dict:
"""Full profile for /api/user/me. Default: derive from `user`."""
return user.to_request_user()
def public_config(self) -> dict:
"""Non-secret descriptor the UI fetches to pick its UI mode."""
return {"provider": self.name}
The decorators stay as the public API and become thin wrappers that delegate to the active provider. Their required-vs-optional semantics are preserved:
| Decorator | NoCredentials | Invalid | Authenticated |
|---|---|---|---|
requires_auth |
401 | 401 | check perms, set request.user |
optional_enrollment |
request.user = {} |
request.user = {} |
set request.user |
set_userid(...) moves into the shared decorator path so it applies to every provider.
(The old Auth0-only DEV_MASQUERADE_USER debug override was dropped — it had fallen out of
use, and the none provider covers the "run as a fixed user" case.)
Update (merged with
main): the app-wide enrollment gate was removed upstream, sorequires_enrollmentis gone — its former call sites now use@requires_auth(). TheAUTH0_REQUIRED_PERMISSION/NEXT_PUBLIC_AUTH0_REQUIRED_PERMISSIONenv vars and the UI'shasRequiredPermissionwere removed accordingly. Explicitrequires_auth(required_permission=...)(e.g. the metrics ADMIN routes) is unchanged.
3. Activation / config
# api/utils/auth/factory.py
def get_auth_provider() -> AuthProvider: # cached singleton
kind = os.environ.get("AUTH_PROVIDER", "none")
return {
"auth0": Auth0Provider,
"password_file": PasswordFileProvider,
"none": NoneProvider,
}[kind].from_env()
New env var AUTH_PROVIDER (defaults to none for zero-config local runs; existing
and production deploys must set AUTH_PROVIDER=auth0 explicitly).
Each provider reads only its own settings via from_env().
4. The three providers
Auth0Provider — lifts verify_token / JWKS / /userinfo out of auth.py verbatim.
authenticate() parses the Bearer header (NoCredentials if missing, Invalid on bad
token) and returns AuthenticatedUser from the JWT claims. user_profile() calls
/userinfo. No functional change.
NoneProvider — authenticate() always returns the fixed user and never raises, so
both required and optional decorators just work:
AuthenticatedUser(
sub="local", # hardcoded fixed identity
name="Local User", email="local@localhost", email_verified=True,
permissions=ALL_PERMISSIONS, # every PermissionType value -> all features unlocked
)
PasswordFileProvider — the substantive one.
- Store: a JSON file (atomic write, structured so we can carry email/name/permissions), schema versioned:
- Login: new endpoint
POST /api/auth/login {username, password}. Validates against the file (password verified only here), then issues a short-lived HS256 JWT signed withAUTH_SESSION_SECRETcontaining{sub:"file|alice", username, name, email, permissions, iat, exp}(permissionsis for client-side UI gating only — see the per-request note below). Returns{token, expires_at}. PyJWT is already a dependency. - Per-request:
authenticate()verifies the token signature, then re-reads the file to confirm the user still exists and is notdisabled, and loads currentpermissionsfrom the file (not from the token). This is what satisfies "consult the file on every check": new users can log in immediately, permission/enable changes take effect on the next request, deleted users are locked out on their next request — all with no web-stack restart. (The file read is cheap; we can add an mtime-guarded cache later if needed, but default to reading each request for correctness.) - Hashing:
bcrypt(recommended; robust, salted). Stdlibhashlib.scryptis a zero-new-dependency fallback. Decision flagged below. - Shared store/hashing logic lives in
api/utils/auth/password_store.py, imported by both the provider and the CLI so the format/hash never drift.
5. Admin CLI (password_file administration)
A script at api/scripts/auth_admin.py (the API isn't an installed package), run via
uv run api/scripts/auth_admin.py ... or python scripts/auth_admin.py ... from api/.
The store directory comes from --dir or $AUTH_PASSWORD_DIR (the file inside is the
fixed passwddb.json):
uv run api/scripts/auth_admin.py useradd alice --email a@x.org --name Alice \
--permission enroll:autodiscovery_admin # prompts for password
uv run api/scripts/auth_admin.py passwd alice
uv run api/scripts/auth_admin.py usermod alice --add-permission enroll:higher_upload_limit
uv run api/scripts/auth_admin.py disable alice # / enable
uv run api/scripts/auth_admin.py userdel alice
uv run api/scripts/auth_admin.py list
Writes are atomic (temp file + os.replace). Because the backend re-reads on every check,
CLI changes are live immediately. The CLI ships in the same image so an operator can
kubectl exec and run it against the mounted file.
6. Auth0 Management-API usage in offline scripts
packages/.../auth0.py (email-by-id for completion emails / CSV export) is Auth0-specific
and runs offline, not in the request path. Out of scope for swapping; left as-is. If a
password_file deployment needs those scripts, they can read the same file via
password_store. Noted, not built now.
7. New / changed env
| Var | Provider | Notes |
|---|---|---|
AUTH_PROVIDER |
all | none (default) | auth0 | password_file, or a comma-separated list of providers |
AUTH_PASSWORD_DIR |
password_file | dir holding the store (fixed passwddb.json); mounted as a directory |
AUTH_SESSION_SECRET |
password_file | HS256 signing secret |
AUTH_SESSION_TTL |
password_file | token lifetime (default e.g. 12h) |
AUTH_TRUSTED_HEADER_SECRET |
trusted_header | the bearer a trusted upstream sends |
AUTH_TRUSTED_HEADER_USER |
trusted_header | header(s) naming the user (default X-Forwarded-User) |
AUTH0_* |
auth0 | unchanged |
(The none provider needs no configuration — its local identity is hardcoded.)
Frontend design
1. Provider-agnostic context
Replace the Auth0-specific context with a generic useAuth() keeping the same shape
(so AuthButton, route layouts, BaseApi, ViewerRunsContext, Heap loader, etc. barely
change). useAuth0 is kept as a thin alias during migration.
interface AuthContextType {
isAuthenticated: boolean;
isLoading: boolean;
user: AuthUser | undefined; // {sub, name, email, picture}
permissions: string[]; // NEW: centralized
hasPermission: (p: string) => boolean;// NEW: replaces ad-hoc JWT decodes
login: (creds?: {username: string; password: string}) => Promise<void>;
logout: () => void;
getAccessToken: () => Promise<string>; // '' when there is no token (e.g. "none")
hasRequiredPermission: boolean;
canExploreWithAsta: boolean; // = hasPermission('enroll:asta_integration')
authError: string | null;
}
The two client-side JWT-decode sites (Auth0Context, metrics/layout) are refactored to
read permissions / hasPermission(...) from context, so they work identically for any
provider.
2. Runtime provider selection
A top-level <AuthProvider> fetches GET /api/auth/config on boot and renders the
matching implementation:
Auth0AuthProvider— current@auth0/auth0-spa-jslogic;login()= redirect;getAccessToken()=getTokenSilently();permissionsfrom decoded JWT.PasswordFileAuthProvider— renders a username/password login form;login(creds)→POST /api/auth/login, stores token (localStorage);getAccessToken()returns the stored token;permissionsdecoded from our HS256 JWT; logout clears storage.NoneAuthProvider— immediatelyisAuthenticated=trueas the local user;login/logoutare no-ops;getAccessToken()returns''(the bridge'sgetTokenreturnsnull, so noAuthorizationheader is sent);permissions= all. No login UI.
This makes the active mode a runtime decision — one build serves all three. (Auth0's
NEXT_PUBLIC_AUTH0_* values can be served from /api/auth/config too, so they no longer
have to be baked at build time. Decision flagged below.)
3. Decoupling BaseApi from Auth0
BaseApi no longer imports auth0Client. Introduce a tiny module-level bridge the active
provider populates:
// ui/src/app/auth/authBridge.ts
export const authBridge = {
getToken: async (): Promise<string | null> => null, // set by active provider
getUserId: async (): Promise<string | null> => null,
onUnauthorized: () => {}, // 401 handler (redirect / clear)
};
createDefaultHeaders() calls authBridge.getToken(); the 401 path calls
authBridge.onUnauthorized(). For none, getToken returns null → no Authorization
header, and the backend NoneProvider ignores it anyway.
4. UI behavior per provider
| Concern | auth0 | password_file | none |
|---|---|---|---|
| Login affordance | "Sign in" → redirect | login form (modal/page) | none (always in) |
AuthButton |
Sign in/out | Sign in/out (form) | hidden |
| Token transport | bearer (silent) | bearer (stored) | none |
| Permissions source | JWT claims | JWT claims (file-derived at login) | all |
Rollout
- Backend: extract
auth.pyintoauth/(models, base, factory, providers) with the three decorators delegating —auth0path behavior-identical. Add/api/auth/config. - Backend:
password_store+PasswordFileProvider+/api/auth/login+ CLI. - Backend:
NoneProvider. - Frontend: generic
useAuth()+authBridge, refactorBaseApiand the two decode sites;Auth0AuthProviderparity. - Frontend:
PasswordFileAuthProvider(+ login form) andNoneAuthProvider. - Docs +
.env.example; k8s manifests for mounting the password file.
Each step keeps auth0 working, so we can land incrementally.
Decisions (locked)
- Password-file session: signed HS256 token issued at login (mirrors the existing Bearer flow); file re-read every request to validate user + load permissions.
- Password hashing: bcrypt.
- UI config: Auth0 settings served from runtime
GET /api/auth/config— true single-build swap. - CLI home: a script at
api/scripts/auth_admin.py(the API runs from/apivia gunicorn and is not an installed package), run withuv run api/scripts/auth_admin.py .... - Scope: implement all steps (backend + frontend), keeping
auth0working at each step.
As built
Backend file layout
api/utils/auth/ # package (replaces the old auth.py module)
__init__.py # re-exports: requires_auth, optional_enrollment,
# PermissionType, get_auth_provider, ...
models.py # AuthenticatedUser
base.py # AuthProvider ABC + AuthError/NoCredentials/Invalid/Config
permissions.py # PermissionType, ALL_PERMISSIONS
decorators.py # the three decorators, delegating to the active provider
factory.py # get_auth_provider() (cached) / reset_auth_provider()
password_store.py # bcrypt + atomic JSON store (shared with the CLI)
providers/
_bearer.py # shared Authorization-header parsing
auth0.py # Auth0Provider (verify_token/JWKS/userinfo lifted over)
password_file.py # PasswordFileProvider (HS256 session, re-reads file/request)
none.py # NoneProvider (fixed "local" user, all permissions)
api/auth/auth_api.py # blueprint: GET /api/auth/config, POST /api/auth/login
api/scripts/auth_admin.py # password_file admin CLI
request.user remains the same dict; request.auth_user additionally carries the
typed AuthenticatedUser (used by /api/user/me → provider.user_profile).
Frontend file layout
ui/src/app/auth/authBridge.ts # singleton so BaseApi has no Auth0 dependency
ui/src/app/auth/authConfig.ts # fetchAuthConfig() + decodeJwtPayload() + fallbacks
ui/src/app/auth/Auth0Client.ts # createAuth0Client(config) factory (was a module singleton)
ui/src/app/contexts/Auth0Context.tsx # generic context + runtime selector + 3 impls
ui/src/app/components/LoginDialog.tsx # username/password modal (password_file)
useAuth0() (alias useAuth) and the provider export stays
Auth0Provider, so the ~20 existing consumers and ClientProviders are unchanged. The
context gained permissions, hasPermission(), login(creds?), and provider. The
interactive trigger loginWithRedirect is now generic (Auth0 redirect / open the
password form / no-op for none), so AuthButton and IntroBox did not change their
call. AuthButton hides itself under the none provider.
Operating the password_file provider
Setup, the auth_admin.py CLI, and mounting are covered in the
Authentication guide. This section stays focused on
design; see the guide for how-to.
Deltas from the proposal
- The consumer-facing hook kept the name
useAuth0(aliasuseAuth) instead of being renamed, to avoid churning ~20 files; the implementation underneath is provider-agnostic. loginWithRedirectwas repurposed as the generic interactive-login trigger rather than adding a newpromptLogin, so existing call sites needed no change.- The password-file session token also carries
permissions(for client-side UI gating only); the backend still re-reads the file for every authoritative check. - No k8s manifests exist in-repo (deploy is via Skiff/Cloud Run), so wiring is limited to
.env.exampleanddocker-compose.yaml; mount the store file the same way as the GCP key.