Auth
Two providers ship: APIKeyAuth (static bearer keys, each with a name that becomes the turn owner; for local dev, scripts and service-to-service callers) and JWTAuth (OIDC discovery, an explicit JWKS endpoint, or a local HS256 secret). Both ship with pip install "pyrula[agents]".
from pyrula.agents import create_app, AppOptionsfrom pyrula.workflows.web import JWTAuth
app = create_app( agents=[my_agent], store=store, llm=llm, options=AppOptions( auth=JWTAuth.from_oidc( issuer="https://accounts.example.com", audience="pyrula-api", ), ),)from_oidc fetches /.well-known/openid-configuration at startup and rotates keys in the background. A key missing from the cache triggers one refresh before a 401.
Constructors
Section titled “Constructors”JWTAuth.from_oidc(issuer, audience, *, leeway=60, required_scopes=()): the token’sissmust matchissuerexactly. For Auth0, Okta, Cognito or any compliant IdP.JWTAuth.from_jwks(jwks_uri, audience, *, issuer=None, leeway=60, required_scopes=()): explicit key set;issueris optional.JWTAuth.hs256(secret, audience, *, issuer=None, leeway=60, required_scopes=()): local shared secret, no network.
from_oidc / from_jwks also accept algorithms, allow_insecure_http, http_timeout, jwks_cache_ttl, jwks_min_refresh_interval, scope_claims and subject_claim.
Per-subject ownership
Section titled “Per-subject ownership”The sub claim is the turn owner: that subject can read, stream and cancel its turns. Anyone else gets 404, same as a missing id. To use a tenant id or allow an admin scope:
from pyrula.contracts.web import AuthConfig
AuthConfig( provider=JWTAuth.from_oidc(...), principal_to_owner=lambda p: p.claims.get("org_id", p.subject), authorize_run=lambda p, owner: str(p) == owner or "admin" in p.scopes,)Principal exposes .subject, .claims, .scopes and .token. .token is the live credential: don’t log or persist it in a custom deps_factory or principal_to_owner. Under the default mapping the router stores only .subject. repr(principal) leaves out .token and .claims.
Scopes
Section titled “Scopes”JWTAuth.from_oidc( issuer="https://accounts.example.com", audience="pyrula-api", required_scopes=["agents:write"],)Scopes are read from scope (space-separated), scopes or permissions. A token missing any required scope gets 403.
Security properties
Section titled “Security properties”- The token’s
algheader never selects the verifier: JWKS resolvers pin RS256/ES256/PS256 by key type, and HS256 uses only the local secret. audienceis required; aNoneor empty value is rejected, as is a token without a matchingaud.from_oidcandfrom_jwksrefuse non-localhosthttp://URIs.- Key refresh is throttled to once per 30 seconds.
- If the JWKS endpoint is down and no keys are cached, the server returns 503, not 401.
- Under the default mapping only the
subvalue is persisted; raw tokens and other claims never reach the log. The owner stays in the runtime: it is never sent to Pyrula Cloud, and the events endpoints leave it out.
API key
Section titled “API key”Give APIKeyAuth one key per caller, keyed by a name:
import os
from pyrula.agents import APIKeyAuth
options = AppOptions( auth=APIKeyAuth({"ci": os.environ["CI_API_KEY"], "ops": os.environ["OPS_API_KEY"]}),)The caller’s identity is the matching key’s name, never the key. The name is the turn and thread owner, so ci cannot read a turn ops submitted. To rotate a key, replace its value under the same name: turns submitted with the old value stay readable with the new one, and the old value is refused. A single string, APIKeyAuth("dev-key"), is shorthand for {"api-key": "dev-key"}. A name must be non-empty and contain neither : nor \x1f; an empty key, or two names sharing one key, is refused at construction. The mapping is name to key: {key: "ci"} would make the key the name, and so the stored owner. A missing header gets a 401 with auth_missing and a wrong key a 401 with auth_invalid; neither says which names exist.
Both providers are FastAPI dependencies returning the caller’s identity (or raising 401/403). A custom provider is any such callable; there is no base class.
Running without auth
Section titled “Running without auth”With no provider every route is open to anyone who can reach the port. The server allows it but not quietly:
create_appand a directly mountedAgentRouterlog aNo auth configuredwarning at build time.create_app(options=AppOptions(host=...))raisesConfigurationErrorfor a non-loopback host with no auth, unlessallow_unauthenticated=True. It only sees ahostyou declare inAppOptions, not the one you later give uvicorn.- The first request from a non-loopback address to a router with no auth setting logs one
ERRORper process. It never refuses the request, because a proxy or Docker port map makes the peer address unreliable. - To run open on purpose, pass
AppOptions(auth=None)(orAuthConfig(provider=None)), which skips the warning.