loom.rest.auth¶
Authentication for the Loom REST layer.
The layer is mechanism-agnostic: AuthenticationMiddleware drives any
Authenticator and publishes the resulting
Identity for the duration of the
request. JwtAuthenticator and its ready-made
JwtAuthMiddleware are the batteries-included implementation, bound
from the app.rest.auth.jwt config section.
Install the optional JWT dependency with:
pip install "loom-kernel[jwt]"
- class loom.rest.auth.AuthenticationMiddleware(app, *, authenticator, exclude_paths=())[source]¶
Bases:
objectAuthenticates every HTTP request through a pluggable mechanism.
On each HTTP request whose path is not excluded:
Builds
RequestCredentialsfrom the ASGI scope.Asks the
Authenticatorfor an identity.On refusal, answers
401with the framework’s standard error body and aWWW-Authenticatechallenge. The message is deliberately generic for every failure mode (no oracle), and the refusal is logged atINFO— the response and the log have different audiences, and only the response has an attacker in it. Never the credential: a log holding a bearer token turns log access into API access.On success, installs the identity for the duration of the request and restores the previous one in a
finally— without it, a reused worker task would inherit the previous caller.
Non-HTTP scopes (WebSocket, lifespan) are passed through unchanged.
- Parameters:
app (_ASGIApp) – The ASGI application to wrap.
authenticator (Authenticator) – Mechanism that verifies callers.
exclude_paths (Sequence[str]) – Exact request paths served without authentication.
Example:
app.add_middleware( AuthenticationMiddleware, authenticator=MyApiKeyAuthenticator(store), exclude_paths=("/health",), )
- class loom.rest.auth.Authenticator(*args, **kwargs)[source]¶
Bases:
ProtocolTurns the credentials of one request into a verified identity.
Implementations must be stateless with respect to the request and safe to share across concurrent calls: one instance serves the whole application.
Example:
class ApiKeyAuthenticator: name = "api-key" provides_roles = True async def authenticate(self, credentials): key = credentials.header("x-api-key") owner = await self._keys.owner_of(key) if key else None if owner is None: return None return Identity(subject=owner.id, roles=owner.roles, mechanism=self.name)
- property provides_roles: bool¶
Whether the mechanism binds roles to the identity.
Startup gates rely on this: an endpoint whose authorization is role-based refuses to mount behind a mechanism that issues none, rather than letting every authenticated caller pick their own privileges.
- async authenticate(credentials)[source]¶
Verify credentials and return the caller they identify.
- Parameters:
credentials (RequestCredentials) – Headers, path and peer address of the request.
- Returns:
The verified identity, or
Noneto refuse the request. The refusal carries no reason on purpose: the response must not become an oracle about which part of the credentials failed.- Return type:
Identity | None
- class loom.rest.auth.JwtAuthConfig(*, secret_path=None, public_keys=<factory>, algorithms=(), audience=None, issuer=None, leeway_seconds=0, exclude_paths=('/docs', '/redoc', '/openapi.json', '/metrics', '/health'), roles_claim=None)[source]¶
Bases:
LoomFrozenStructValidated settings for
JwtAuthMiddleware.Binds from the
app.rest.auth.jwtconfig section. Validation runs on construction (fail-fast): exactly one key source must be provided and the algorithm allowlist must be non-empty and coherent with that key source.- secret_path¶
Filesystem path of the shared secret for symmetric algorithms (HS*). A path and not the value: this is a
msgspec.Struct, so any serializer emits its fields verbatim and a config dump would publish the key that verifies and signs. Mutually exclusive withpublic_keys.- Type:
str | None
- public_keys¶
Static PEM-encoded public keys for asymmetric algorithms (RS*/ES*/EdDSA), keyed by the
kidthat selects them. Mutually exclusive withsecret.
- algorithms¶
Explicit allowlist of accepted JWT algorithms. The
nonealgorithm is always forbidden.
- roles_claim¶
Name of the verified claim carrying the roles the caller is authorized to use. Consumed by the SQL endpoint: the effective roles are the claim values intersected with the connection allowlist, and the request body can only narrow that set.
- Type:
str | None
- Raises:
ConfigError – If key sources, algorithms, leeway, or the roles claim name are invalid.
- Parameters:
Example YAML:
app: rest: auth: jwt: secret_path: ${oc.env:LOOM_JWT_SECRET_PATH} algorithms: [HS256] roles_claim: loom_sql_roles- classmethod from_signing_key(private_key_path=None, *, private_key_ref=None, key_ref_region=None, kid, algorithms, audience=None, issuer=None, leeway_seconds=0, exclude_paths=DEFAULT_EXCLUDE_PATHS, roles_claim=None, additional_public_keys=None)[source]¶
Build a verifier whose public key is derived from the signing key.
For the service that both issues and verifies. Two configured values that must match are two values that can disagree, and a stale public key does not fail loudly — it accepts nothing, or accepts what a rotated key signed. Deriving removes the second value.
- Parameters:
private_key_path (str | None) – Filesystem path of the PEM signing key. Only the derived public key is kept; the private material is dropped. Mutually exclusive with
private_key_ref.private_key_ref (str | None) – Managed-store reference of the PEM signing key, as accepted by
JwtIssuerConfig.private_key_ref. Mutually exclusive withprivate_key_path.key_ref_region (str | None) – AWS region for the ref resolver. Requires
private_key_ref.kid (str) – Key identifier the derived public key is published under.
algorithms (tuple[str, ...]) – Explicit allowlist of accepted JWT algorithms.
audience (str | None) – Expected
audclaim. Validated only when set.issuer (str | None) – Expected
issclaim. Validated only when set.leeway_seconds (int) – Clock-skew tolerance for time-based claims.
exclude_paths (tuple[str, ...]) – Exact request paths that bypass authentication.
roles_claim (str | None) – Verified claim carrying the caller roles.
additional_public_keys (dict[str, str] | None) – Extra public keys by
kid, so the previous key stays published for one rotation window. Public material, so unlike the signing key it is safe to carry as a value. Reusing the derivedkidhere is refused: this method exists so the published key cannot drift from the signing key, and an override would reintroduce the drift.
- Returns:
A validated
JwtAuthConfigpublishing the derived key.- Raises:
ConfigError – If the sources are not exactly one, or the signing key cannot be read or parsed. The cause is chained: parser errors name the reason and never carry the material.
- Return type:
- class loom.rest.auth.JwtAuthMiddleware(app, *, config)[source]¶
Bases:
objectStateless JWT bearer authentication, as a ready-made middleware.
Thin composition over
AuthenticationMiddlewareandJwtAuthenticator: it exists so applications that only need JWT wire one class instead of two.- Parameters:
app (_ASGIApp) – The ASGI application to wrap.
config (JwtAuthConfig) – Validated
JwtAuthConfig.
- Raises:
ImportError – If the optional
pyjwtdependency is not installed.
Example — FastAPI:
from loom.rest.auth import JwtAuthConfig, JwtAuthMiddleware config = JwtAuthConfig(secret_path="/run/secrets/jwt", algorithms=("HS256",)) app.add_middleware(JwtAuthMiddleware, config=config)
- class loom.rest.auth.JwtAuthenticator(config)[source]¶
Bases:
objectAuthenticates callers from a stateless JWT bearer token.
Verification is fully stateless: no server-side session storage and no remote JWKS fetch. Signature,
expandsubare always required (a token without a subject carries no identity to bind an authorization decision to, nor to audit afterwards);aud/issare validated only when configured.- Parameters:
config (JwtAuthConfig) – Validated JWT settings.
- Raises:
ImportError – If the optional
pyjwtdependency is not installed.
Example:
authenticator = JwtAuthenticator( JwtAuthConfig( secret_path="/run/secrets/jwt", algorithms=("HS256",), roles_claim="loom_sql_roles", ) )
- async authenticate(credentials)[source]¶
Verify the bearer token and project its claims onto an identity.
- Parameters:
credentials (RequestCredentials) – Headers and path of the request.
- Returns:
The verified identity, or
Nonewhen the header is absent, uses another scheme, or the token fails verification.- Return type:
Identity | None
- class loom.rest.auth.JwtIssuer(config)[source]¶
Bases:
objectMints JWT bearer tokens for a verified
Identity.Everything the token says comes from the identity: there is no way for a caller to add a claim, so nobody can widen their own roles or speak for another subject through this door.
The signing key is read once here and never kept on the config, which is a
msgspec.Structwhose fields any serializer would publish.Every issuing is logged at INFO with the subject and the roles granted. That is a deliberate audit trail, not diagnostics: without it an access is not attributable to who asked for the token nor to the privileges it carried. The subject is usually personal data, so route these logs accordingly. The token and the key are never logged.
- Parameters:
config (JwtIssuerConfig) – Validated issuer settings.
- Raises:
ImportError – If the optional
pyjwtdependency is not installed.ConfigError – If the signing key cannot be read, or cannot sign with the configured algorithm.
Example:
issuer = JwtIssuer(JwtIssuerConfig( private_key_path="/run/secrets/jwt.pem", algorithm="EdDSA", audience="my-api", issuer="my-gateway", roles_claim="loom_sql_roles", )) issued = issuer.issue(identity)
- issue(identity, *, ttl=None)[source]¶
Mint a token for identity.
- Parameters:
- Returns:
The token, its expiry and its
jti.- Raises:
ValueError – If the identity is anonymous, carries no role while a roles claim is configured, holds an attribute that would be unreadable once encoded, or ttl is out of bounds.
RuntimeError – If signing fails. The cause is not chained, so the caller learns nothing about the key or the algorithm.
- Return type:
- class loom.rest.auth.JwtIssuerConfig(*, private_key_path=None, private_key_ref=None, key_ref_region=None, secret_path=None, algorithm='', audience='', issuer='', roles_claim='', ttl_seconds=900, kid=None, allow_symmetric_signing=False)[source]¶
Bases:
LoomFrozenStructValidated settings for
JwtIssuer.Separate from
JwtAuthConfigon purpose, and not a few extra fields on it: a service that only verifies must have no configuration path through which signing material can land in its process. The fields diverge anyway — one algorithm instead of an allowlist,audienceandissuermandatory rather than optional, a lifetime the verifier has no use for.The private key is read from
private_key_pathand never held as a field.__repr__redaction would not be enough: this is amsgspec.Struct, somsgspec.json.encodeandto_builtinsemit every field verbatim without going through it, and a config dump would publish the signing key.- private_key_path¶
Filesystem path of the PEM signing key, for asymmetric algorithms. Mutually exclusive with the other sources.
- Type:
str | None
- private_key_ref¶
Managed-store reference of the PEM signing key, as
"<resolver>:<key>"with resolver one ofsecretsorssm(e.g."secrets:/myapp/prod/jwt-signing-key"). Resolved once, when the issuer loads the key, so the material never touches disk or config — and rotating the stored value requires a restart. The Secrets Manager resolver navigates dots as JSON paths, so the key name must not contain.. Mutually exclusive with the other sources.- Type:
str | None
- key_ref_region¶
AWS region the ref resolver is built with. Defaults to boto3’s own resolution chain. Requires
private_key_ref.- Type:
str | None
- secret_path¶
Filesystem path of the shared secret for HS* algorithms. Requires
allow_symmetric_signing. Mutually exclusive with the other sources.- Type:
str | None
- algorithm¶
The single algorithm tokens are signed with. Issuing chooses one; only verification negotiates an allowlist.
- Type:
- audience¶
audstamped on every token. Mandatory: a token without an audience is valid at any service that shares the key.- Type:
- roles_claim¶
Claim carrying the caller roles. Mandatory, and never a registered claim: an issuer that does not own that name lets an attribute impersonate it. An identity without roles is refused.
- Type:
- ttl_seconds¶
Lifetime of a minted token, in
1..MAX_ISSUER_TTL_SECONDS, and the ceiling for any per-call override.- Type:
- kid¶
Key identifier stamped on the token header, so a verifier can select the right key during a rotation overlap.
- Type:
str | None
- allow_symmetric_signing¶
Opt-in required for HS*. With a shared secret every verifier can also mint, so the choice must be deliberate.
- Type:
- Raises:
ConfigError – If key sources, algorithm, audience, issuer, roles claim or lifetime are invalid.
- Parameters:
Note
Built programmatically, not bound from a config section: issuing happens in an application use case that injects the issuer, not in the REST layer that owns
app.rest.auth. Resolve the values however the service resolves its own settings.Example:
config = JwtIssuerConfig( private_key_path=os.environ["JWT_SIGNING_KEY_PATH"], algorithm="EdDSA", audience="my-api", issuer="my-gateway", roles_claim="loom_sql_roles", ttl_seconds=900, kid="2026-08", )
- load_signing_key()[source]¶
Load the signing key, so it lives in the issuer and not in the config.
- Returns:
The key material, read from disk or resolved from the managed store when
private_key_refis set.- Raises:
ConfigError – If the file cannot be read or is not UTF-8 text (the cause is never chained: an OS error carries the path, and a
UnicodeDecodeErrorcarries the file contents onexc.object), or if the managed store cannot answer (that cause is chained: resolver errors never carry the material).- Return type:
- class loom.rest.auth.RequestCredentials(headers, path, client_host=None)[source]¶
Bases:
objectWhat an authenticator is allowed to look at, free of any ASGI type.
Deliberately narrow: everything an authentication mechanism may legitimately read, and nothing that would let it reach into request handling. The body is absent on purpose — authenticating on it would require buffering the request before deciding whether the caller exists.
- headers¶
Request headers keyed by lowercase name.
- Type:
Example:
credentials = RequestCredentials( headers={"authorization": "Bearer ..."}, path="/sql/analytics", )