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:
objectAuth capability mixin, composed BEFORE the session/middleware/server classes.
Constructor kwargs peeled here:
auth— the credential config dict ({'basic': ..., 'bearer': ..., 'jwt': [...]});Nonearms 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 whenusersis absent).- Parameters:
kwargs (Any)
- property user_store: UserStore | None
The local identity store, or
Nonewhen nousersis configured.
- property api_key_store: ApiKeyStore | None
The api-key registry, or
Nonewhen notokensis configured.
- authenticate(request)[source]
Resolve the request identity: header credentials win, else the session.
The
Authorizationheader is API-first — a valid credential yields anAvatar, an invalid one raisesHTTPUnauthorized(no fallback). Without a header, the session avatar is returned (Nonewhen no session capability is composed or the session is anonymous).
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:
objectPer-instance credential store and header verifier for basic/bearer/jwt.
- Parameters:
api_key_store (ApiKeyStore | None)
entries (Any)
- __init__(api_key_store=None, **entries)[source]
Build the credential store from the configured sections.
api_key_storeis the wired registry theAuthMixinpasses: when present, an inboundgak_bearer token is verified against it before the static-bearer/JWT chain.- Parameters:
api_key_store (ApiKeyStore | None)
entries (Any)
- 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*, apublic_key) can only verify and are skipped — thesigningprovenance flag guards them even when the algorithm was defaulted, so public key material can never mint tokens.Nonewhen no symmetric verifier is configured.
- authenticate(scope)[source]
Verify the request’s credentials; return an
AvatarorNone.Nonewhen noAuthorizationheader is presented; a present but invalid credential — including a malformed header with no scheme/value separator — raisesHTTPUnauthorized(no silent fallback) carrying aWWW-Authenticate: Bearerchallenge header.- Return type:
- 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:
RoutingClassBase contract for a login method attached under
_server/auth/<id>.A concrete method sets
kindand (usually)method_id, overridesdescriptor()to add what itskindneeds (a label, an entry URL), and adds its own routes with@routewhen 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 throughself.application(dual relationship), the AsgiServer throughself.application.server.- Parameters:
application (Any)
method_id (str)
- class genro_asgi.auth.auth_method.PasswordMethod(application, method_id)[source]
Bases:
AuthMethodThe 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/loginroute (store-backed via the UserStore). This method only declares itself to the login page as aformso the page renders the identity/password form and posts it there.- Parameters:
application (Any)
method_id (str)
- genro_asgi.auth.auth_method.safe_next_path(value, default='/')[source]
Return a login
nexttarget that is safe to redirect to, ordefault.The login flow carries a
nextparameter 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 todefault.
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:
AuthMethodAn OIDC provider as a
redirectlogin method (authorization-code + PKCE).Owns the
startroute (and thecallbackroute, 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; theclient_secret(when configured) stays inproviderand is never logged nor exposed by the descriptor.- 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 isapplication.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, optionalclient_secret,scopes,identity_claim,tags) — defaults already applied at the config fold.
- Return type:
None
- 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-configurationon 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).
- start_url()[source]
The
startroute 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:
- callback_url()[source]
The ABSOLUTE
callbackurl — theredirect_urihanded 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 noexternal_url, so this never renders aNoneprefix.- Return type:
- descriptor()[source]
Public descriptor for the login page: a labelled redirect button.
Extends the base
{id, kind}with thelabel(derived from the provider code) and theurlthe button navigates to (thestartroute). Deliberately carries no client id, issuer, or secret — it is the unauthenticatedlogin_methodspayload.
- async start(next='', _request=None)[source]
Begin the authorization-code + PKCE flow: redirect to the provider.
Mints a fresh
stateand PKCEverifier, stores them and the safenexttarget underoidc.<method_id>.*in the session’sdata(mark_dirtyso the callback can read them back), then redirects the browser to the provider’s authorization endpoint with the S256 challenge. Theclient_secretis 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 bysafe_next_path(same-origin relative only) before it is stored._request – The live
Request, injected by the app’sbind_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:
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 thestateand the authorizationcode. ThestateMUST match the valuestartstored 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. Thecodeis exchanged for tokens on the provider’s token endpoint with the stored PKCEverifier(and theclient_secretwhen configured); the returnedid_tokenis verified (RS256 against the provider JWKS, audience =client_id, issuer = the configured issuer). The identity is read from theidentity_claimclaim (config defaultemail), the tags from the provider config, and theAvataris attached to the request’s session in place — the session id never changes, so no cookie is involved. The one-shotoidc.<method_id>.*keys are cleared, and the browser is redirected to the safenextstartstored.- 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 bybind_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
nexton success.
- Return type:
Note
Route: GET /_server/auth/<method_id>/callback
- async jwk_client()[source]
The provider’s JWKS client, built once and kept for the instance.
PyJWKClientcaches the fetched key set (its ownlifespan), which only pays off if the client SURVIVES the request — a fresh one per callback would re-fetch every time. Built from the lazily discoveredjwks_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:
objectContract 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.
- 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.
- hash_password(password)[source]
Hash
passwordwith scrypt: a fresh salt, params embedded in the string.
- class genro_asgi.auth.user_store.FileUserStore(storage, mount='secure', prefix='users')[source]
Bases:
UserStoreOne JSON file per user over a storage mount (encrypted
secureby default).Like
FileSessionStoreit holds the sharedLocalStorage(dual relationship) and never raw paths — the mount decides encryption at rest. Records live at<mount>:<prefix>/<identity>.json.- Parameters:
storage (LocalStorage)
mount (str)
prefix (str)
- __init__(storage, mount='secure', prefix='users')[source]
Bind the store to a storage
mount/prefix(defaultsecure:users).- Parameters:
storage (LocalStorage)
mount (str)
prefix (str)
- Return type:
None
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:
objectContract for the API key registry (see module docstring).
Subclasses implement persistence; key generation, hashing and verification live here so every backend behaves identically.
- 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_atis a POSIX timestamp; None means the key never expires.
- class genro_asgi.auth.api_key_store.FileApiKeyStore(storage, mount='secure', prefix='api_keys')[source]
Bases:
ApiKeyStoreOne JSON file per key over a storage mount (encrypted
secureby default).Like
FileUserStoreit holds the sharedLocalStorage(dual relationship) and never raw paths — the mount decides encryption at rest. Records live at<mount>:<prefix>/<key_id>.json.- Parameters:
storage (LocalStorage)
mount (str)
prefix (str)
- __init__(storage, mount='secure', prefix='api_keys')[source]
Bind the store to a storage
mount/prefix(defaultsecure:api_keys).- Parameters:
storage (LocalStorage)
mount (str)
prefix (str)
- Return type:
None