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
RouteMeta.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:
RouteMetais static serializable data, so anAuthSpeccarries 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 existing
strRouteMeta.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
Parsestr | AuthSpec | None into a canonical AuthSpec or None.
EXACT back-compat with the historicalenforce_route_meta_authstring semantics — this preserves…
Detect whether the request is from an API client (not a browser).
Heuristic:
- Has
Authorizationheader -> API client Acceptprefers JSON over HTML…
Build a login redirect URL with anextparameter.
Build the content-negotiated unauthenticated response.
Browser -> 302 redirect to the login URL (withnext); API -> 401.
Emitsauth.require.unauthenticatedfor the API/no-login-url…
Return whetherrequired is in held, comparing in constant time.
A plainrequired in heldset membership leaks (via early-exit string compare) how…
Authenticate-or-deny the resolveduser against spec.
This is the single shared gate. It performs, in order:
- Authentication — if
useris not authenticated,…
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 string
s->AuthSpec(permissions=(s,))(single required permission) - an existing
AuthSpecpasses 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:
- Has
Authorizationheader -> 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:
- Authentication — if
useris not authenticated, raise the content-negotiated response (302 -> login for browsers, 401 for APIs). - Permission check — when
spec.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. - Policy — when
spec.policyis set, resolve it viapolicy_resolverand callpolicy(user, request); deny (403) only on a falsy result from the RESOLVED callable (a real denial). - Scope check (machine auth) — when
spec.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 noscopesruns no scope step, so existingverify_tokenusers are never newly denied (no separate enable flag). Denial emitsauthz.scope.deniedwithdetails={"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