security.auth_core

Page actions AI-ready formats and sharing
Open LLM text
Share with AI
Ask Claude Ask ChatGPT Ask Gemini Ask Copilot

Shared authenticate-or-deny core for declarative and imperative auth.

This module is the single source of gate logic used by both:

  • the imperative decorators@login_required / @requires (chirp.security.decorators); and
  • the declarative …

Shared authenticate-or-deny core for declarative and imperative auth.

This module is the single source of gate logic used by both:

  • the imperative decorators@login_required / @requires (chirp.security.decorators); and
  • the declarativeRouteMeta.authgate (enforce_route_meta_auth()).

Before this module the two paths diverged:@requiresemitted richer audit payloads (details={"missing": sorted(missing)}) plus a _log.warning, while the declarative gate emitted a different payload shape and no log. That divergence was the security risk — downstream SIEM keyed off one shape but not the other. The core converges both on ONE canonical payload (documented in src/chirp/security/AGENTS.md).

Design constraints:

  • RouteMeta is static serializable data, so an AuthSpeccarries a policy name (str), never a callable. The core therefore takes an injectedpolicy_resolver (name -> callable | None) so it stays registry-agnostic; the policy registry itself is wired in a later phase.
  • Every existingstr RouteMeta.authvalue keeps identical runtime meaning vianormalize_auth_spec().

security.auth_core

Name Type Default Description
type
qualified_name
element_type
description
source_file
line_number
is_autodoc
autodoc_element
_autodoc_template
_autodoc_url_path
_autodoc_page_type
title
doc_content_hash

Symbols on this page

normalize_auth_spec
function
def normalize_auth_spec(auth: str | AuthSpec | None) -> AuthSpec | None

Parsestr | AuthSpec | None into a canonical AuthSpec or None.

EXACT back-compat with the historicalenforce_route_meta_authstring semantics — this preserves the runtime meaning of every existing value:

  • None / "" / "none" / "optional" -> None(open, no gate)
  • "required" -> AuthSpec()(authn-only, no permissions/policy)
  • any other non-empty strings-> AuthSpec(permissions=(s,))(single required permission)
  • an existingAuthSpecpasses through unchanged.

AnAuthSpecalways requires authentication, so the open case is the only one that returnsNone; there is no requiredflag to set.

Parameters

Name Type Default Description
auth str | AuthSpec | None
_is_api_request
function
def _is_api_request(request: Any) -> bool

Detect whether the request is from an API client (not a browser).

Heuristic:

  • HasAuthorizationheader -> API client
  • Acceptprefers JSON over HTML -> API client
  • Otherwise -> browser

Parameters

Name Type Default Description
request Any
_build_login_redirect
function
def _build_login_redirect(login_url: str, request_url: str) -> str

Build a login redirect URL with anextparameter.

Parameters

Name Type Default Description
login_url str
request_url str
_deny_unauthenticated
function
def _deny_unauthenticated(request: Any) -> HTTPError

Build the content-negotiated unauthenticated response.

Browser -> 302 redirect to the login URL (withnext); API -> 401. Emitsauth.require.unauthenticatedfor the API/no-login-url branches, matching historical behavior on both paths (the redirect branch does not emit — preserved verbatim).

Parameters

Name Type Default Description
request Any
_scope_held
function
def _scope_held(required: str, held: frozenset[str]) -> bool

Return whetherrequired is in held, comparing in constant time.

A plainrequired in heldset membership leaks (via early-exit string compare) how many leading characters of a scope matched. Webhook/cron scope tokens can be secret-bearing, so the issue's success criterion is a constant-time compare — route every scope-name equality through compare_digest() (the same primitive csrf.py/ passwords.py use), never ==. The iteration count still varies with len(held), but each individual scope comparison is constant-time.

Parameters

Name Type Default Description
required str
held frozenset[str]
enforce_auth
function async
async def enforce_auth(spec: AuthSpec, request: Request, user: Any, *, policy_resolver: PolicyResolver | None = None) -> None

Authenticate-or-deny the resolveduser against spec.

This is the single shared gate. It performs, in order:

  1. Authentication — ifuseris not authenticated, raise the content-negotiated response (302 -> login for browsers, 401 for APIs).
  2. Permission check — whenspec.permissionsis non-empty, the user must implement the permissions protocol (else 403) and satisfy the set: mode="all"requires every permission (subset); mode="any"requires a non-empty intersection.
  3. Policy — whenspec.policyis set, resolve it via policy_resolver and call policy(user, request); deny (403) only on a falsy result from the RESOLVED callable (a real denial).
  4. Scope check (machine auth) — whenspec.scopesis non-empty, the resolved client must implement the scopes protocol (ClientWithScopes, else 403) and satisfy the scope set underspec.mode. This is the machine-token axis, independent of permissions: a token-resolved client with the scope but no permissions passes, while a human user with permissions but not the scope fails. Scope-equality usescompare_digest() (constant-time). Scope enforcement is implicitly off — a spec with no scopes runs no scope step, so existing verify_tokenusers are never newly denied (no separate enable flag). Denial emits authz.scope.denied with details={"missing": sorted([...])}.

An unresolved policy NAME (nopolicy_resolverwired, or the resolver returnsNone) is a MISCONFIGURATION, not an auth denial: it raises LookupError-> a 500 at request time, consistent with the page wrapper's _resolve_policy (app/registry.py). It is NOT a 403 and emits NO authz.policy.deniedevent. This 500 is only a runtime backstop — the real guard is theauth_specstartup contract check, which ERRORs on any referencedAuthSpec.policythat is not registered.

Audit events use the ONE canonical payload (seeAGENTS.md); the permission/policy warnings are logged viachirp.security.

Parameters

Name Type Default Description
spec AuthSpec The resolved, non-open ``AuthSpec`` to enforce.
request Request The active request (for content negotiation + audit context).
user Any The resolved current user (``get_user()`` result).
policy_resolver PolicyResolver | None None ``name -> callable | None`` for named policies. Only consulted when ``spec.policy`` is set. An unresolved name (no resolver, or the resolver returns ``None``) fails loud (``LookupError`` -> 500), never a silent 403.

View source · /home/runner/work/chirp/chirp/site/../src/chirp/security/auth_core.py:1