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 writebe/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 writebe/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:

Generatedfe/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 editfe/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 editfe/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)} />
    ))}
  );
}