Skip to content
Pyrula
Pre-release: the packages are not on PyPI yet, so pip install does not resolve. Build from source to try it today.

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, AppOptions
from 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.

  • JWTAuth.from_oidc(issuer, audience, *, leeway=60, required_scopes=()): the token’s iss must match issuer exactly. For Auth0, Okta, Cognito or any compliant IdP.
  • JWTAuth.from_jwks(jwks_uri, audience, *, issuer=None, leeway=60, required_scopes=()): explicit key set; issuer is 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.

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.

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.

  • The token’s alg header never selects the verifier: JWKS resolvers pin RS256/ES256/PS256 by key type, and HS256 uses only the local secret.
  • audience is required; a None or empty value is rejected, as is a token without a matching aud.
  • from_oidc and from_jwks refuse non-localhost http:// 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 sub value 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.

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.

With no provider every route is open to anyone who can reach the port. The server allows it but not quietly:

  • create_app and a directly mounted AgentRouter log a No auth configured warning at build time.
  • create_app(options=AppOptions(host=...)) raises ConfigurationError for a non-loopback host with no auth, unless allow_unauthenticated=True. It only sees a host you declare in AppOptions, not the one you later give uvicorn.
  • The first request from a non-loopback address to a router with no auth setting logs one ERROR per 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) (or AuthConfig(provider=None)), which skips the warning.

View this page as Markdown