# Authentication & sessions *Layer: Backend · identity & sessions (cross-cutting)* The `auth` block on `project.jsonnet` declares how a request becomes a session. Reach for it whenever the app needs logged-in users, "Sign in with …", or an anonymous principal for `public: true` routes. ## How it works The model is server-authoritative: the signed JWT carries only a token id, and a [`TokenStore`](permissions.md) resolves it to a live `Session` on every request (logout / epoch-bump revoke instantly — there is no stateless mode). You supply the domain types the config references by dotted path (`Session`, `validate_login`, `map_oidc_identity`, `public_session`); codegen emits the `get_session` dep, the login / logout / callback routes, and the FE bridge. ## The config you write **You write** — `be/config/project.jsonnet` (the `auth` block, `import "be/auth/jwt.libsonnet"`): ```jsonnet auth: auth.config({ session_schema: "auth.Session", // Attribute path to the human behind the token: owner scoping + the // OPA subject's nested user id. token_id_attr (default "token_id") is // the OPA subject id and logout's revocation key. user_id_attr: "user.id", // REQUIRED — no stateless mode. A fsh_lib.auth.TokenStore over the // tokens table; resolves the JWT's token id every request, revokes on // logout, rejects epoch-bumped tokens (password / role change). session_store: "token_store.store", // Anonymous principal, minted once by the generated // get_session_or_public dep for public: true routes; what it sees is // gated by the `public` role's OPA bindings. Required iff any route // sets public: true (see resources.md). public_session: "auth.public_session", sources: ["bearer", "cookie"], // token transports get_session accepts cookie_secure: false, // local HTTP dev; flip true under HTTPS secret_env: "JWT_SECRET", token_url: "/auth/token", // path of login / read-session / logout // The read-session response always embeds collection permissions // (SessionWithPermissions), so useSession() gates nav + columns with // no extra request. (This flag is a legacy no-op — permissions embed // regardless — but harmless.) include_permissions: true, methods: [ // Username/password (alice / wonderland). validate_fn returns the // minted token id (str) or None -> 401. auth.password({ credentials_schema: "auth.LoginCredentials", validate_fn: "auth.validate_login", }), // "Sign in with Google" (OIDC). Browser flow: FE button navigates to // /v1/auth/oauth/google/login -> consent -> .../callback, which // verifies the ID token, calls identity_fn, sets the cookie, and 302s // back into the SPA (appending ?oauth_error= on failure). auth.oauth({ identity_fn: "auth.map_oidc_identity", // Origins from env so no host is baked into the build: api_base_url_env: "API_URL", // BE origin (callback lands here) app_base_url_env: "APP_URL", // SPA origin (success / error) success_path: "/", error_path: "/login", // falls back to success_path providers: [ auth.provider({ slug: "google", preset: "google", // else "discover" (needs issuer) / "manual" client_id: "296326345867-....apps.googleusercontent.com", // public client_secret_env: "GOOGLE_CLIENT_SECRET", // Exactly one redirect source: callback_path joined onto // api_base_url_env, OR a literal redirect_uri. The full URL // must be registered on the Google client. callback_path: "/v1/auth/oauth/google/callback", scopes: ["openid", "email", "profile"], }), ], }), ], }), ``` **You write** — `be/auth.py` (the domain types the block references; the JWT carries only `token_id`, the store hydrates the rest): ```python class Session(BaseModel): token_id: str # OPA subject id + logout's revocation key user: SessionUser # loaded from the token; user.id is user_id_attr roles: list[str] = Field(default_factory=list) # live role NAMES for in-process `can` locale: str = "en-US" async def validate_login(creds: LoginCredentials, db: AsyncSession) -> str | None: row = (await db.execute( select(User.id, User.password_hash, User.is_active, User.token_epoch) .where(User.username == creds.username), )).one_or_none() if row is None: return None user_id, password_hash, is_active, token_epoch = row if not is_active: # soft-deleted account return None if password_hash is None: # OAuth-provisioned, no password return None if not verify_password(creds.password, password_hash): return None # Mint a token row, return its id for fsh_lib.auth.issue_session to sign. return await _mint_token(db, user_id=user_id, token_epoch=token_epoch, source=TokenSource.LOGIN) async def map_oidc_identity(identity: OidcIdentity, _tokens: TokenResponse, db: AsyncSession) -> str | None: # Never key an account off an unverified email. if not identity.email or identity.email_verified is not True: return None email = identity.email.lower() existing = (await db.execute( select(User.id, User.token_epoch).where(User.email == email), )).one_or_none() if existing is not None: user_id, token_epoch = existing return await _mint_token(db, user_id=user_id, token_epoch=token_epoch, source=TokenSource.OAUTH) user_id = await _provision_oauth_user(db, email) # auto-provision first sign-in return await _mint_token(db, user_id=user_id, token_epoch=0, source=TokenSource.OAUTH) def public_session() -> Session: # A full Session so every serializer / OPA call treats it uniformly; # what it sees is governed by the `public` role's bindings. return Session( token_id="public", user=SessionUser(id="00000000-0000-0000-0000-000000000000", username="public"), roles=["public"], ) ``` ## The frontend codegen emits The bridge is regenerated every run — stable aliases for the BE's auth routes, plus the OAuth provider list and login URLs. Hand-owned code imports from here, never the raw SDK: **Generated** — `fe/src/_generated/auth.gen.ts` (do not edit): ```typescript import { client } from "./client.gen"; export { createTokenV1AuthTokenPost as loginRequest } from "./sdk/auth/auth.gen"; export { readSessionV1AuthTokenGet as validateRequest } from "./sdk/auth/auth.gen"; export { logoutV1AuthTokenLogoutPost as logoutRequest } from "./sdk/auth/auth.gen"; export type AuthCredentials = LoginCredentials; // login body export type AuthSession = SessionWithPermissions; // validate response (perms embedded) export const oauthProviders = ["google"] as const; // one per auth.provider // Top-level *navigation* target (not an SDK fetch), built off the client's // base origin so it tracks VITE_API_URL automatically. export function oauthLoginUrl(provider: OAuthProvider): string { return `${client.getConfig().baseUrl}${OAUTH_LOGIN_PATHS[provider]}`; } ``` Codegen scaffolds the wrappers and hooks **once** (`if_exists=skip`); you own them after. The FE never sees a bearer token — the BE session cookie carries it: **Scaffolded, safe to edit** — `fe/src/api/auth.ts` + `fe/src/api/session.ts`: ```typescript // api/auth.ts — the client throws on non-2xx, so only the success path is written. export function login(credentials: AuthCredentials): Promise { // Cookie is set on success; read the session back to seed the query cache. return loginRequest({ body: credentials }).then(() => validateRequest()); } export function validate(): Promise { return validateRequest().catch(() => null); // 401 -> null ("not signed in") } export function logout(): Promise { return logoutRequest().then(() => undefined); } // api/session.ts — hand the typed pair to fsh's factory (it owns the TanStack // Query plumbing); useSession() gates nav items + table columns off the // embedded permissions with no extra request (see table-configuration.md). export const { sessionQueryKey, useSession, useSessionStatusSuspense, useLogout } = createSessionHooks({ validate, logout }); ``` **Scaffolded, safe to edit** — `fe/src/auth/Login.tsx` (password form + OAuth buttons; auth-state transitions are full-page by design): ```tsx export function Login() { useOAuthErrorToast(); // reads ?oauth_error= off a failed callback redirect const form = useFSHForm({ defaultValues: { username: "", password: "" }, // Hard-nav on success: beforeLoad's auth gate reads context.session, // which only updates across a commit boundary a submit tail can't cross. submit: (values) => login(values).then(() => window.location.assign("/")), }); return ( {/* username + password inputs + Sign in */} /* Social login sits OUTSIDE the form — each is a navigation, not a submit. */ {oauthProviders.map((provider) => ( ))} ); } ```