Authentication

The auth mixin, the credential core, the auth methods (password, OIDC), and the identity stores.

Auth capability: header/session identity resolution as a mixin (D16).

AuthMixin is composed BEFORE SessionMixin/MiddlewareMixin/ BaseServer (class S(AuthMixin, SessionMixin, MiddlewareMixin, BaseServer)). Its cooperative __init__ peels auth= (the config dict; None builds an AuthCore with no header backends armed) and ARMS AuthMiddleware by injecting {"auth": True} into the middleware config it forwards along the cooperative chain — the same mechanism SessionMixin uses, so composing the mixins arms header auth with no user action while an explicit middleware={"auth": False} still wins.

It also wires the server’s identity stores. users= and tokens= each take a config dict ({mount, prefix}, defaulting to secure:users / secure:api_keys) OR a ready store instance; admin_password= seeds the bootstrap admin. The stores are built AFTER super().__init__() returns — by then the cooperative chain has run and self.storage exists, since AuthMixin precedes StorageMixin in the MRO. The user_store / api_key_store properties return None when unconfigured (the shape login already reads). A config dict without storage on the server is a boot error (no silent fallback); a ready instance needs no storage. admin_password with no users= config implies the default users store. The bootstrap admin is an UPSERT at boot (config wins): runtime edits to the admin record do not survive a reboot.

It overrides the §4 contract method authenticate(request) with the §5.5 identity precedence: an Authorization header wins (API-first) — its AuthCore verdict is an Avatar or a raised HTTPUnauthorized; with no header the request’s session avatar is used. “Nobody” is None uniformly: an anonymous session carries avatar is None and self.session(request) returns None unchanged when SessionMixin is absent, so the precedence degrades to None in both cases. In the middleware chain SessionMiddleware (order 400) runs OUTSIDE AuthMiddleware (order 450), so the session is already on the scope when the fallback runs.

class genro_asgi.auth.mixin.AuthMixin(**kwargs)[source]

Bases: object

Auth capability mixin, composed BEFORE the session/middleware/server classes.

Constructor kwargs peeled here: auth — the credential config dict ({'basic': ..., 'bearer': ..., 'jwt': [...]}); None arms no header backend but still resolves the session identity through §5.5 precedence. users / tokens — a {mount, prefix} config dict or a ready store instance for the identity/api-key stores; admin_password — the bootstrap admin’s password (implies the default users store when users is absent).

Parameters:

kwargs (Any)

property auth_core: AuthCore

The credential store backing this server’s header authentication.

property user_store: UserStore | None

The local identity store, or None when no users is configured.

property api_key_store: ApiKeyStore | None

The api-key registry, or None when no tokens is configured.

authenticate(request)[source]

Resolve the request identity: header credentials win, else the session.

The Authorization header is API-first — a valid credential yields an Avatar, an invalid one raises HTTPUnauthorized (no fallback). Without a header, the session avatar is returned (None when no session capability is composed or the session is anonymous).

Return type:

Any

Parameters:

request (Any)

Auth core: credential configuration and header verification (salvage Q5).

AuthCore holds the whole credential configuration and the verification logic for the basic, bearer and jwt schemes. It is router-free — configured per instance from {'basic': ..., 'bearer': ..., 'jwt': [...]} and consumed by the server-side AuthMixin (never by walking wrappers).

Config sections (_configure_<type> dynamic dispatch, unknown sections ignored):

basic:  {username: {password: "...", tags: "..."}} — O(1) lookup keyed
        by the base64 username:password the Basic header carries.
bearer: {name: {token: "...", tags: "..."}} — O(1) lookup keyed by the
        token value.
jwt:    [{secret|public_key, algorithm, tags, name}, ...] — a list of
        verifier configs, each tried in turn and decoded with pyjwt.

authenticate(scope) reads the Authorization header via the shared headers_dict cache, dispatches _auth_<scheme> and returns an Avatar (the ONE identity type, unified with sessions) or None when NO credentials are presented. Credentials PRESENT but invalid raise HTTPUnauthorized — never a silent fallback; a malformed header (no scheme/value separator) is present-but-invalid, so it raises too. Tag normalization (e.g. a JWT null tags claim) happens at the Avatar boundary, never list(None).

class genro_asgi.auth.core.AuthCore(api_key_store=None, **entries)[source]

Bases: object

Per-instance credential store and header verifier for basic/bearer/jwt.

Parameters:
__init__(api_key_store=None, **entries)[source]

Build the credential store from the configured sections.

api_key_store is the wired registry the AuthMixin passes: when present, an inbound gak_ bearer token is verified against it before the static-bearer/JWT chain.

Parameters:
Return type:

None

property signing_jwt_config: dict[str, Any] | None

The first symmetric (HS*) JWT verifier config, usable for signing.

A read-only seam for the tokens surface: HMAC verifiers share one secret for sign and verify, so a token minted here verifies against the same config with zero new key material. Asymmetric verifiers (RS*/ES*, a public_key) can only verify and are skipped — the signing provenance flag guards them even when the algorithm was defaulted, so public key material can never mint tokens. None when no symmetric verifier is configured.

authenticate(scope)[source]

Verify the request’s credentials; return an Avatar or None.

None when no Authorization header is presented; a present but invalid credential — including a malformed header with no scheme/value separator — raises HTTPUnauthorized (no silent fallback) carrying a WWW-Authenticate: Bearer challenge header.

Return type:

Avatar | None

Parameters:

scope (MutableMapping[str, Any])

Auth methods — self-describing login mechanisms mounted under _server/auth.

An auth method is one way a user proves who they are: a password form today, an OIDC redirect to an external provider in the next wave. Each method is a RoutingClass; the AuthSection attaches it under _server/auth/<method_id> ONLY when it owns routes (OIDC will own start + callback). The password method owns none — it adapts the existing /_server/login — so it is never attached: it lives only in the section’s registry (Invariant 10: zero-route nodes never enter the routing tree). The method is also self-describing: descriptor() is the small dict the login page renders it from (an id, a kind, a label, the entry URL for redirect methods).

The contract:

AuthMethod (RoutingClass):
    method_id: str          # "password", "oidc:google", ...
    kind: str               # "form" | "redirect" | "ceremony"
    descriptor() -> dict    # what the login page renders it from

kind tells the page how to draw the method: form renders the identity/password form (the password method), redirect renders one button navigating to the method’s entry URL (OIDC), ceremony is a client-driven exchange (passkey, a later wave). Every method converges on the SAME success outcome: the avatar attached to the request’s session through request.session.attach_avatar — the session id never changes at login, so no cookie is involved and no method or handler ever sets one.

safe_next_path is the shared open-redirect guard for the login next parameter: the login page mirrors it client-side today; the challenge redirect (ErrorMiddleware, next phase) will apply it server-side, so one rule governs every path.

class genro_asgi.auth.auth_method.AuthMethod(application, method_id)[source]

Bases: RoutingClass

Base contract for a login method attached under _server/auth/<id>.

A concrete method sets kind and (usually) method_id, overrides descriptor() to add what its kind needs (a label, an entry URL), and adds its own routes with @route when it has any (OIDC does; password does not — a route-less method is recorded in the AuthSection registry but never attached to the routing tree, Invariant 10). The ServerApplication that owns the login surface is reached through self.application (dual relationship), the AsgiServer through self.application.server.

Parameters:
  • application (Any)

  • method_id (str)

kind: str = ''

“form” | “redirect” | “ceremony”. A concrete method sets it.

Type:

The method kind

__init__(application, method_id)[source]

Bind the method to its login surface and fix its id.

Parameters:
  • application (Any) – The ServerApplication the login surface belongs to (dual relationship). The AsgiServer is application.server.

  • method_id (str) – The method’s identity and its mount name under _server/auth (e.g. "password", "oidc:google").

Return type:

None

property method_id: str

The method’s identity, also its mount name under _server/auth.

property server: Any

The AsgiServer, reached through the parent ServerApplication.

descriptor()[source]

The dict the login page renders this method from.

The base carries the two facts every method has — its id and its kind; a concrete method adds what its kind needs (a label, a url for redirect methods) by extending this dict.

Return type:

dict[str, Any]

class genro_asgi.auth.auth_method.PasswordMethod(application, method_id)[source]

Bases: AuthMethod

The password method: a thin adapter over /_server/login.

Owns no routes and is therefore never attached to the routing tree (Invariant 10) — it lives only in the AuthSection registry. The credential check and session promotion live on the ServerApplication’s /_server/login route (store-backed via the UserStore). This method only declares itself to the login page as a form so the page renders the identity/password form and posts it there.

Parameters:
  • application (Any)

  • method_id (str)

kind: str = 'form'

“form” | “redirect” | “ceremony”. A concrete method sets it.

Type:

The method kind

descriptor()[source]

Descriptor for the password form: adds where the page posts.

The page posts credentials to the existing /_server/login JSON route (action); label names the form for the page.

Return type:

dict[str, Any]

genro_asgi.auth.auth_method.safe_next_path(value, default='/')[source]

Return a login next target that is safe to redirect to, or default.

The login flow carries a next parameter through the challenge and back; an attacker who can shape it into an off-site URL turns the login page into an open redirect. The single guard keeps only a same-origin relative path: it must start with a single / and must not be a scheme-relative URL (//host) nor carry a backslash a browser would normalize to /. Anything else (an absolute URL, javascript:, an empty value, a bare path without the leading slash) collapses to default.

Return type:

str

Parameters:
  • value (str | None)

  • default (str)

OIDC login method — authorization-code + PKCE redirect to an external provider.

OidcMethod is the redirect auth method: one instance per configured provider, registered on the ServerApplication from the oidc() config. Unlike the password method it OWNS routes, so AuthSection attaches it under /_server/auth/<method_id>/ (method_id = oidc:<code>): the start route and the callback route the provider redirects back to.

Provider metadata (the authorization/token endpoints, the JWKS uri) is NOT fetched at construction: a server booting while its provider is unreachable must not die. discovery() fetches <issuer>/.well-known/openid-configuration LAZILY at first use and caches it per instance.

The redirect_uri handed to the provider is ABSOLUTE (RFC 6749 §3.1.2) and built from the server’s external_url — its declared public base address. Two properties make that the only workable source: the provider compares the URI byte for byte with the one registered for the client, and the token exchange must repeat the SAME value in a context where no request exists. A server carrying a provider without external_url refuses to boot.

The start route begins the authorization-code flow with PKCE (S256, always on — the client_secret is optional, a public client works without one and the secret is never logged nor put on the wire here). It mints a fresh state and PKCE verifier, stores them and the safe next target in the session’s data (mark_dirty so they survive to the callback), and redirects the browser to the provider’s authorization endpoint. The callback route closes the round trip: it checks the state, exchanges the code for tokens on the provider’s token endpoint (PKCE verifier, client_secret when configured), verifies the id_token (RS256 against the provider JWKS, audience/issuer checked), and converges on the same success outcome every method shares — the Avatar attached to the session — then redirects to the stored safe next.

descriptor() marks the method a redirect for the login page: a labelled button navigating to the start url (login.html renders kind=redirect buttons). The descriptor is the PUBLIC login_methods payload — it carries the id, kind, label and start url ONLY, never the client id, issuer, or secret.

class genro_asgi.auth.oidc_method.OidcMethod(application, method_id, code, provider)[source]

Bases: AuthMethod

An OIDC provider as a redirect login method (authorization-code + PKCE).

Owns the start route (and the callback route, next phase), so it is attached under /_server/auth/<method_id>/ (Invariant 10: a method with routes enters the routing tree). Discovery is lazy and cached; PKCE is always S256; the client_secret (when configured) stays in provider and is never logged nor exposed by the descriptor.

Parameters:
  • application (Any)

  • method_id (str)

  • code (str)

  • provider (dict[str, Any])

kind: str = 'redirect'

“form” | “redirect” | “ceremony”. A concrete method sets it.

Type:

The method kind

__init__(application, method_id, code, provider)[source]

Bind the method to its login surface and its provider config.

Parameters:
  • application (Any) – The ServerApplication the login surface belongs to (dual relationship). The AsgiServer is application.server.

  • method_id (str) – The mount name under _server/auth (oidc:<code>).

  • code (str) – The provider’s config code (also the label source).

  • provider (dict[str, Any]) – The resolved provider config (issuer, client_id, optional client_secret, scopes, identity_claim, tags) — defaults already applied at the config fold.

Return type:

None

property code: str

The provider’s config code.

property provider: dict[str, Any]

The resolved provider config (carries the secret — never exposed).

async discovery()[source]

The provider’s OIDC metadata, fetched once and cached per instance.

Fetches <issuer>/.well-known/openid-configuration on first use — never at construction, so a server whose provider is unreachable still boots. The cached document carries the authorization/token endpoints and the JWKS uri (the last two consumed by the callback in the next phase).

Return type:

dict[str, Any]

start_url()[source]

The start route url — the descriptor entry the login page navigates to.

RELATIVE by design: the login page navigates to it within our own site, so it needs no host (and works on every address the server answers on).

Return type:

str

callback_url()[source]

The ABSOLUTE callback url — the redirect_uri handed to the provider.

Absolute because the provider redirects a browser to it from another origin and matches it against the URI registered for the client (RFC 6749 §3.1.2); relative here would be rejected before the user ever sees a login screen. Built from the server’s declared external_url, so the authorization request and the token exchange — which has no request to derive a host from — send the identical string. The server refuses to boot with a provider configured and no external_url, so this never renders a None prefix.

Return type:

str

descriptor()[source]

Public descriptor for the login page: a labelled redirect button.

Extends the base {id, kind} with the label (derived from the provider code) and the url the button navigates to (the start route). Deliberately carries no client id, issuer, or secret — it is the unauthenticated login_methods payload.

Return type:

dict[str, Any]

async start(next='', _request=None)[source]

Begin the authorization-code + PKCE flow: redirect to the provider.

Mints a fresh state and PKCE verifier, stores them and the safe next target under oidc.<method_id>.* in the session’s data (mark_dirty so the callback can read them back), then redirects the browser to the provider’s authorization endpoint with the S256 challenge. The client_secret is NOT used here (it belongs to the token exchange in the callback) — the authorization request is a public redirect.

Parameters:
  • next (str) – Post-login redirect target, validated by safe_next_path (same-origin relative only) before it is stored.

  • _request – The live Request, injected by the app’s bind_kwargs. Left unannotated so it stays out of the pydantic model (and the public OpenAPI schema); reached for _request.session.

Raises:

Redirect – 302 to the provider’s authorization endpoint.

Return type:

str

Note

Route: GET /_server/auth/<method_id>/start

async callback(state='', code='', _request=None)[source]

Close the authorization-code flow: exchange, validate, attach, redirect.

The provider redirects the browser here (redirect_uri = callback_url) with the state and the authorization code. The state MUST match the value start stored in the session (a mismatch or a missing state is refused, never retried): it binds the callback to the session that began the flow and defeats CSRF. The code is exchanged for tokens on the provider’s token endpoint with the stored PKCE verifier (and the client_secret when configured); the returned id_token is verified (RS256 against the provider JWKS, audience = client_id, issuer = the configured issuer). The identity is read from the identity_claim claim (config default email), the tags from the provider config, and the Avatar is attached to the request’s session in place — the session id never changes, so no cookie is involved. The one-shot oidc.<method_id>.* keys are cleared, and the browser is redirected to the safe next start stored.

Parameters:
  • state (str) – The opaque value echoed back by the provider; must match the session’s stored state.

  • code (str) – The authorization code to exchange for tokens.

  • _request – The live Request, injected by bind_kwargs. Left unannotated so it stays out of the pydantic model (and the public OpenAPI schema); reached for _request.session.

Raises:
  • HTTPBadRequest – On a missing/mismatched state, a failed token exchange, or an invalid id_token.

  • Redirect – 302 to the stored safe next on success.

Return type:

str

Note

Route: GET /_server/auth/<method_id>/callback

async jwk_client()[source]

The provider’s JWKS client, built once and kept for the instance.

PyJWKClient caches the fetched key set (its own lifespan), which only pays off if the client SURVIVES the request — a fresh one per callback would re-fetch every time. Built from the lazily discovered jwks_uri, so it inherits the offline-boot guarantee.

Return type:

PyJWKClient

UserStore — the server’s local identity source (contract + file backend).

The UserStore is the server’s in-house equivalent of an external identity provider (LDAP, OIDC): it holds users with a password and a set of tags and verifies credentials. Its tags are the verified claims of the local source; they ride in the authentication result as trusted input (the receiving app stays sovereign on what it keeps — the two-phase model).

Contract (clients depend on this, never on files or SQL — a future DbUserStore swaps behind it):

UserStore:
    load_all() -> list[dict]                    # every record
    get(identity) -> dict | None                # one record or None
    save(record) -> None                        # create or update
    delete(identity) -> bool                    # True if a record was removed
    verify(identity, password) -> dict | None   # full record on success, None otherwise

FileUserStore is the filesystem backend: one JSON file per user at <mount>:<prefix>/<identity>.json over the Phase 1 storage nodes. It defaults to the encrypted secure mount, so records are ciphertext at rest; without installed key material that mount hard-fails (D5 — no plain-text fallback). All I/O is synchronous (core 1b ratified: async callers wrap in server.run_sync()).

The record:

{
  "identity": "admin",
  "password_hash": "scrypt$n=16384,r=8,p=1$<salt-b64>$<hash-b64>",
  "tags": ["SUPERADMIN"],
  "enabled": true,
  "failed_attempts": 0,
  "last_failed_at": 0.0
}

failed_attempts/last_failed_at are OPTIONAL (absent until the first failure): the store-backed login-lockout counter the _server login route maintains — incremented on failure, reset on success (applications/server_app.py).

Passwords are hashed with hashlib.scrypt (stdlib, zero new deps): a random per-user salt and the cost parameters are embedded in the hash string, so a future parameter upgrade needs no migration. Comparison is constant-time (hmac.compare_digest). Plain-text passwords never persist anywhere.

verify returns the full record on success and None on ANY failure (unknown user, disabled account, wrong password) — a disabled user never authenticates. identity is the immutable record key (a rename is delete + create).

class genro_asgi.auth.user_store.UserStore[source]

Bases: object

Contract for a local identity store (see module docstring).

Subclasses implement persistence; the password hashing and verification logic lives here so every backend hashes identically. Clients depend on this contract only, never on files or SQL.

load_all()[source]

Return every stored record.

Return type:

list[dict[str, Any]]

get(identity)[source]

Return the record for identity or None if there is no such user.

Return type:

dict[str, Any] | None

Parameters:

identity (str)

save(record)[source]

Persist record (create or update).

Return type:

None

Parameters:

record (dict[str, Any])

delete(identity)[source]

Remove identity. True if a record was removed, False if absent.

Return type:

bool

Parameters:

identity (str)

verify(identity, password)[source]

Return the full record when the password matches an enabled user.

Returns None on ANY failure (unknown user, disabled, wrong password), with a constant-time comparison against the stored hash.

Return type:

dict[str, Any] | None

Parameters:
  • identity (str)

  • password (str)

hash_password(password)[source]

Hash password with scrypt: a fresh salt, params embedded in the string.

Return type:

str

Parameters:

password (str)

check_password(password, stored)[source]

Constant-time check of password against a stored scrypt hash string.

Parses the salt and cost parameters out of stored (so old hashes stay verifiable after a parameter upgrade). A malformed hash string yields False.

Return type:

bool

Parameters:
class genro_asgi.auth.user_store.FileUserStore(storage, mount='secure', prefix='users')[source]

Bases: UserStore

One JSON file per user over a storage mount (encrypted secure by default).

Like FileSessionStore it holds the shared LocalStorage (dual relationship) and never raw paths — the mount decides encryption at rest. Records live at <mount>:<prefix>/<identity>.json.

Parameters:
__init__(storage, mount='secure', prefix='users')[source]

Bind the store to a storage mount/prefix (default secure:users).

Parameters:
Return type:

None

load_all()[source]

Read every *.json record under <mount>:<prefix>/.

Return type:

list[dict[str, Any]]

get(identity)[source]

Read one user’s record, or None if the file does not exist.

Return type:

dict[str, Any] | None

Parameters:

identity (str)

save(record)[source]

Persist record through its storage node (encryption is the mount’s concern).

The record’s identity is its file key: one file per user, written via the node’s public write_text.

Return type:

None

Parameters:

record (dict[str, Any])

delete(identity)[source]

Remove one user’s file. True if it existed, False otherwise.

Return type:

bool

Parameters:

identity (str)

ApiKeyStore — the server’s revocable bearer-credential registry (contract + file backend).

An API key is its own identity (label + tags), registered at issue time and revocable at any moment: unlike a JWT (stateless, valid until it expires), a key is checked against this registry on every request, so disabling the record kills the credential instantly.

Contract (clients depend on this, never on files or SQL — a future DbApiKeyStore swaps behind it):

ApiKeyStore:
    load_all() -> list[dict]                       # every record
    get(key_id) -> dict | None                     # one record or None
    save(record) -> None                            # create or update
    delete(key_id) -> bool                          # True if a record was removed
    issue(label, tags, expires_at=None) -> str      # mint a key, shown ONCE
    revoke(key_id) -> bool                          # True if a record was disabled
    verify(key) -> dict | None                      # record if enabled+unexpired+match

The key: gak_<key_id>_<secret> — recognizable prefix, embedded record id (hex, so the first _ after it splits unambiguously) for O(1) lookup, then a high-entropy secret. The registry stores ONLY the sha256 hash of the secret: a 256-bit random secret is not guessable, so a fast hash suffices (scrypt is for low-entropy passwords, see UserStore). The full key exists in clear only once, as issue’s return value.

FileApiKeyStore is the filesystem backend: one JSON file per key at <mount>:<prefix>/<key_id>.json over the Phase 1 storage nodes. It defaults to the encrypted secure mount, so records are ciphertext at rest; without installed key material that mount hard-fails (D5 — no plain-text fallback). All I/O is synchronous (core 1b ratified: async callers wrap in server.run_sync()).

The record:

{
  "key_id": "3f9c2a1b7d4e6a05",
  "label": "ci-deploy",
  "tags": ["deploy"],
  "enabled": true,
  "expires_at": null,
  "secret_hash": "<sha256 hex>"
}

expires_at is a POSIX timestamp (time.time() convention, matching Session) or None for a key that never expires. Revoke sets enabled: false (the record stays: the audit row remains listed); delete removes the file. verify returns None on ANY failure — malformed key, unknown id, disabled record, expired, or a secret hash mismatch (constant-time comparison).

class genro_asgi.auth.api_key_store.ApiKeyStore[source]

Bases: object

Contract for the API key registry (see module docstring).

Subclasses implement persistence; key generation, hashing and verification live here so every backend behaves identically.

load_all()[source]

Return every stored record.

Return type:

list[dict[str, Any]]

get(key_id)[source]

Return the record for key_id or None if there is no such key.

Return type:

dict[str, Any] | None

Parameters:

key_id (str)

save(record)[source]

Persist record (create or update).

Return type:

None

Parameters:

record (dict[str, Any])

delete(key_id)[source]

Remove key_id. True if a record was removed, False if absent.

Return type:

bool

Parameters:

key_id (str)

issue(label, tags, expires_at=None)[source]

Mint a key: generate, hash, persist, return the full gak_... key.

The returned key is the ONLY time the secret exists in clear — the record carries just its hash. expires_at is a POSIX timestamp; None means the key never expires.

Return type:

str

Parameters:
revoke(key_id)[source]

Disable key_id. True if a record was found and disabled, False if absent.

Return type:

bool

Parameters:

key_id (str)

verify(key)[source]

Return the record when key matches an enabled, unexpired one.

None on ANY failure: malformed key, unknown id, disabled record, expired, or a secret hash mismatch (constant-time comparison).

Return type:

dict[str, Any] | None

Parameters:

key (str)

class genro_asgi.auth.api_key_store.FileApiKeyStore(storage, mount='secure', prefix='api_keys')[source]

Bases: ApiKeyStore

One JSON file per key over a storage mount (encrypted secure by default).

Like FileUserStore it holds the shared LocalStorage (dual relationship) and never raw paths — the mount decides encryption at rest. Records live at <mount>:<prefix>/<key_id>.json.

Parameters:
__init__(storage, mount='secure', prefix='api_keys')[source]

Bind the store to a storage mount/prefix (default secure:api_keys).

Parameters:
Return type:

None

load_all()[source]

Read every *.json record under <mount>:<prefix>/.

Return type:

list[dict[str, Any]]

get(key_id)[source]

Read one key’s record, or None if the file does not exist.

Return type:

dict[str, Any] | None

Parameters:

key_id (str)

save(record)[source]

Persist record through its storage node (encryption is the mount’s concern).

The record’s key_id is its file key: one file per key, written via the node’s public write_text.

Return type:

None

Parameters:

record (dict[str, Any])

delete(key_id)[source]

Remove one key’s file. True if it existed, False otherwise.

Return type:

bool

Parameters:

key_id (str)