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 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"):
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=<reason> 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):
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):
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:
// api/auth.ts — the client throws on non-2xx, so only the success path is written.
export function login(credentials: AuthCredentials): Promise<AuthSession> {
// Cookie is set on success; read the session back to seed the query cache.
return loginRequest({ body: credentials }).then(() => validateRequest());
}
export function validate(): Promise<AuthSession | null> {
return validateRequest().catch(() => null); // 401 -> null ("not signed in")
}
export function logout(): Promise<void> {
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<AuthSession>({ validate, logout });
Scaffolded, safe to edit — fe/src/auth/Login.tsx (password form + OAuth
buttons; auth-state transitions are full-page by design):
export function Login() {
useOAuthErrorToast(); // reads ?oauth_error=<reason> off a failed callback redirect
const form = useFSHForm<AuthCredentials>({
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 (
<FSHForm form={form}>{/* username + password inputs + Sign in */}</FSHForm>
/* Social login sits OUTSIDE the form — each is a navigation, not a submit. */
{oauthProviders.map((provider) => (
<OAuthButton key={provider} provider={provider} href={oauthLoginUrl(provider)} />
))}
);
}