pyapiauth 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pyapiauth/__init__.py ADDED
@@ -0,0 +1,44 @@
1
+ """Verify an OIDC provider's RFC 9068 access tokens inside a Python web API.
2
+
3
+ The Python sibling of goapiauth. A CLI logs in with the device grant, and a
4
+ service with the client-credentials grant, and each sends the provider's access
5
+ token as a bearer. `Verifier` checks it against the issuer's published keys and
6
+ returns the `Caller` it establishes: a person, or a service client limited to
7
+ its scopes.
8
+
9
+ from pyapiauth import LazyVerifier, person_prefix, service_prefix
10
+
11
+ verifier = LazyVerifier('https://auth.example.com', person_prefix('acme'), service_prefix=service_prefix('acme'))
12
+ caller = verifier.verify(token) # Rejected, or IssuerUnreachable
13
+
14
+ The FastAPI dependencies live in `pyapiauth.fastapi_dependencies`, installed
15
+ with the `fastapi` extra.
16
+ """
17
+
18
+ from pyapiauth.errors import AuthError
19
+ from pyapiauth.errors import IssuerUnreachable
20
+ from pyapiauth.errors import Rejected
21
+ from pyapiauth.verifier import USER_AGENT
22
+ from pyapiauth.verifier import Caller
23
+ from pyapiauth.verifier import HeldKeys
24
+ from pyapiauth.verifier import LazyVerifier
25
+ from pyapiauth.verifier import Verifier
26
+ from pyapiauth.verifier import bearer_token
27
+ from pyapiauth.verifier import discover_jwks_uri
28
+ from pyapiauth.verifier import person_prefix
29
+ from pyapiauth.verifier import service_prefix
30
+
31
+ __all__ = [
32
+ 'USER_AGENT',
33
+ 'AuthError',
34
+ 'Caller',
35
+ 'HeldKeys',
36
+ 'IssuerUnreachable',
37
+ 'LazyVerifier',
38
+ 'Rejected',
39
+ 'Verifier',
40
+ 'bearer_token',
41
+ 'discover_jwks_uri',
42
+ 'person_prefix',
43
+ 'service_prefix',
44
+ ]
pyapiauth/errors.py ADDED
@@ -0,0 +1,20 @@
1
+ """Exceptions raised by pyapiauth.
2
+
3
+ Both are one level under `AuthError`. They need opposite answers: a rejected
4
+ token is the caller's to fix by logging in again, and an unreachable issuer is
5
+ nobody's to fix by logging in.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+
11
+ class AuthError(Exception):
12
+ """Base class for every error this package raises."""
13
+
14
+
15
+ class Rejected(AuthError):
16
+ """A token failed a check. The message is for the API's log, never for the caller."""
17
+
18
+
19
+ class IssuerUnreachable(AuthError):
20
+ """The issuer could not be asked, or published no usable keys, so the token was never judged."""
@@ -0,0 +1,92 @@
1
+ """FastAPI dependencies that admit a request by its bearer token, installed with the `fastapi` extra.
2
+
3
+ goapiauth's `RequireBearer` and `LimitServices`, as dependency factories:
4
+
5
+ from fastapi import APIRouter, Depends
6
+ from pyapiauth import LazyVerifier, person_prefix, service_prefix
7
+ from pyapiauth.fastapi_dependencies import person_dependency
8
+
9
+ verifier = LazyVerifier('https://auth.example.com', person_prefix('acme'), service_prefix=service_prefix('acme'))
10
+ router = APIRouter(dependencies=[Depends(person_dependency(verifier))])
11
+
12
+ Each puts the verified `Caller` on `request.state.caller` and returns it. A
13
+ refusal is an `HTTPException`, so FastAPI answers it as `{"detail": ...}`:
14
+
15
+ - **401** with a `WWW-Authenticate` challenge for a missing or rejected token.
16
+ The detail is one opaque sentence, so a caller probing with forged tokens
17
+ learns nothing about which check failed. The reason goes to the log.
18
+ - **503** where the issuer cannot be reached, because a login cannot fix that.
19
+ - **403** where a service client reaches a route it holds no scope for.
20
+
21
+ The bearer is the only credential read. A header a proxy might set, such as
22
+ `Remote-User`, is whatever the caller typed unless the proxy strips it.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import logging
28
+
29
+ from fastapi import HTTPException
30
+ from fastapi import Request
31
+
32
+ from .errors import IssuerUnreachable
33
+ from .errors import Rejected
34
+ from .verifier import Caller
35
+ from .verifier import bearer_token
36
+
37
+ log = logging.getLogger(__name__)
38
+
39
+ UNAUTHORIZED_DETAIL = 'Invalid credentials'
40
+ ISSUER_UNAVAILABLE_DETAIL = 'Identity provider unavailable'
41
+ PERSON_ONLY_DETAIL = 'This route answers a person, not a service client'
42
+ SCOPE_REFUSED_DETAIL = 'This service client holds no scope this route answers'
43
+
44
+
45
+ def caller_dependency(verifier):
46
+ """A dependency admitting any caller whose token `verifier` accepts."""
47
+
48
+ def require_caller(request: Request) -> Caller:
49
+ token = bearer_token(request.headers.get('Authorization', ''))
50
+ if not token:
51
+ log.info('bearer_missing', extra={'path': request.url.path})
52
+ raise HTTPException(401, UNAUTHORIZED_DETAIL, {'WWW-Authenticate': 'Bearer'})
53
+ try:
54
+ caller = verifier.verify(token)
55
+ except IssuerUnreachable as error:
56
+ log.error('issuer_unreachable', extra={'path': request.url.path, 'error': str(error)})
57
+ raise HTTPException(503, ISSUER_UNAVAILABLE_DETAIL) from None
58
+ except Rejected as error:
59
+ log.warning('bearer_rejected', extra={'path': request.url.path, 'error': str(error)})
60
+ raise HTTPException(401, UNAUTHORIZED_DETAIL, {'WWW-Authenticate': 'Bearer error="invalid_token"'}) from None
61
+ request.state.caller = caller
62
+ return caller
63
+
64
+ return require_caller
65
+
66
+
67
+ def person_dependency(verifier):
68
+ """As `caller_dependency`, refusing a service principal with 403."""
69
+ require_caller = caller_dependency(verifier)
70
+
71
+ def require_person(request: Request) -> Caller:
72
+ caller = require_caller(request)
73
+ if caller.service:
74
+ log.info('service_refused', extra={'path': request.url.path, 'client_id': caller.client_id})
75
+ raise HTTPException(403, PERSON_ONLY_DETAIL)
76
+ return caller
77
+
78
+ return require_person
79
+
80
+
81
+ def person_or_scoped_service_dependency(verifier, scope):
82
+ """As `caller_dependency`, admitting a person, or a service principal holding `scope`."""
83
+ require_caller = caller_dependency(verifier)
84
+
85
+ def require_person_or_scope(request: Request) -> Caller:
86
+ caller = require_caller(request)
87
+ if caller.service and scope not in caller.scopes:
88
+ log.info('service_refused', extra={'path': request.url.path, 'client_id': caller.client_id, 'scope': scope})
89
+ raise HTTPException(403, SCOPE_REFUSED_DETAIL)
90
+ return caller
91
+
92
+ return require_person_or_scope
pyapiauth/py.typed ADDED
File without changes
pyapiauth/verifier.py ADDED
@@ -0,0 +1,295 @@
1
+ """Verifying the RFC 9068 access tokens an OIDC provider issues to a product's CLIs and service clients.
2
+
3
+ A CLI logs in with the device authorization grant and sends its access token as
4
+ a bearer. A service logs in with the client-credentials grant and does the same.
5
+ The token is a signed JWT, so the API verifies it against the issuer's published
6
+ keys and asks nothing of the edge. The checks run in this order:
7
+
8
+ 1. the header's `typ` is `at+jwt`
9
+ 2. the signature verifies against a key from the issuer's JWKS
10
+ 3. `iss` is the issuer this API trusts
11
+ 4. `client_id` carries one of this product's two prefixes
12
+ 5. `exp` is in the future
13
+ 6. a person's token, `<product>-cli-*`, carries a `sub`; a service's token,
14
+ `<product>-svc-*`, carries a non-empty `scp`
15
+
16
+ The prefix decides which kind a token is, never the absence of `sub`. A provider
17
+ can omit `sub` from a client-credentials token, and a release can put it back.
18
+ The audience claim is not checked, because some providers leave it empty on a
19
+ device grant's token. The prefix is what keeps a token issued to another
20
+ product's CLI out.
21
+
22
+ The type check comes first because an id_token from the same issuer is signed by
23
+ the same key. Without the check it would pass every later step, and an id_token
24
+ is handed to the client where an access token is not.
25
+
26
+ ## The keys outlive an outage of the issuer
27
+
28
+ `HeldKeys` reads the key set once and holds it. A token whose `kid` no held key
29
+ carries makes it read again, which finds a key the provider has just rotated in.
30
+ Those reads are at least `KEY_REREAD_GAP_SECONDS` apart, so a stream of forged
31
+ tokens costs the provider one read per gap. Keys older than
32
+ `KEY_SET_LIFESPAN_SECONDS` are read again too, which bounds how long a key the
33
+ provider stopped publishing still verifies. A read that fails keeps the held
34
+ keys, because a provider that cannot be read has not rotated its keys out.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import threading
40
+ import time
41
+ from dataclasses import dataclass
42
+
43
+ import httpx2
44
+ import jwt
45
+
46
+ from .errors import IssuerUnreachable
47
+ from .errors import Rejected
48
+
49
+ ACCESS_TOKEN_TYPE = 'at+jwt'
50
+ SIGNING_ALGORITHMS = ['RS256']
51
+ DISCOVERY_PATH = '/.well-known/openid-configuration'
52
+ DISCOVERY_TIMEOUT_SECONDS = 10.0
53
+ KEY_READ_TIMEOUT_SECONDS = 10
54
+ KEY_SET_LIFESPAN_SECONDS = 300.0
55
+ KEY_REREAD_GAP_SECONDS = 30.0
56
+
57
+ # A CDN in front of an issuer can refuse a client that names none. urllib's
58
+ # default agent, which PyJWKClient sends unless told otherwise, is one such.
59
+ USER_AGENT = 'pyapiauth (+https://github.com/datapointchris/pyapiauth)'
60
+
61
+
62
+ def person_prefix(product):
63
+ """The client id prefix of every person's CLI for `product`: `acme-cli-`."""
64
+ return f'{product}-cli-'
65
+
66
+
67
+ def service_prefix(product):
68
+ """The client id prefix of every service client for `product`: `acme-svc-`."""
69
+ return f'{product}-svc-'
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class Caller:
74
+ """What a verified token establishes about who is asking.
75
+
76
+ `subject` is empty for a service principal, which acts as no user. A
77
+ provider's subject can be an opaque id, so `name` carries the token's
78
+ `preferred_username` where the provider copies it into the access token.
79
+ """
80
+
81
+ subject: str
82
+ client_id: str
83
+ scopes: frozenset[str] = frozenset()
84
+ service: bool = False
85
+ name: str = ''
86
+
87
+ @property
88
+ def who(self):
89
+ """The caller as a record shows it: the username, else the subject, else the service client."""
90
+ return self.name or self.subject or self.client_id
91
+
92
+
93
+ def bearer_token(authorization):
94
+ """The credential in an Authorization header, or '' where there is no bearer."""
95
+ scheme, _, credential = authorization.partition(' ')
96
+ if scheme.lower() != 'bearer':
97
+ return ''
98
+ return credential.strip()
99
+
100
+
101
+ def discover_jwks_uri(issuer, client=None, user_agent=USER_AGENT):
102
+ """The issuer's JWKS endpoint, from its discovery document.
103
+
104
+ The document must name the issuer it was asked about. One that names another
105
+ is a misrouted request or a misconfigured proxy, and its keys would verify
106
+ tokens this API was never told to trust.
107
+ """
108
+ client = client or httpx2.Client(timeout=DISCOVERY_TIMEOUT_SECONDS)
109
+ try:
110
+ response = client.get(issuer.rstrip('/') + DISCOVERY_PATH, headers={'User-Agent': user_agent}, timeout=DISCOVERY_TIMEOUT_SECONDS)
111
+ response.raise_for_status()
112
+ document = response.json()
113
+ except (httpx2.HTTPError, ValueError) as error:
114
+ raise IssuerUnreachable(f'discovery at {issuer} failed: {error}') from error
115
+ if document.get('issuer') != issuer:
116
+ raise IssuerUnreachable(f'{issuer} advertises itself as {document.get("issuer")!r}')
117
+ jwks_uri = document.get('jwks_uri')
118
+ if not jwks_uri:
119
+ raise IssuerUnreachable(f'{issuer} advertises no jwks_uri')
120
+ return jwks_uri
121
+
122
+
123
+ class HeldKeys:
124
+ """The issuer's signing keys, read once and held through an outage of the issuer.
125
+
126
+ `read_key_set` returns a `jwt.PyJWKSet`, and raises a `jwt.PyJWTError` where
127
+ the issuer cannot be read. `clock` is a parameter so the lifespan and the
128
+ gap can be driven from a test.
129
+ """
130
+
131
+ def __init__(self, read_key_set, clock=time.monotonic):
132
+ self.read_key_set = read_key_set
133
+ self.clock = clock
134
+ self.keys = {}
135
+ self.read_at = None
136
+ self.tried_at = None
137
+ self.mutex = threading.Lock()
138
+
139
+ @classmethod
140
+ def from_jwks_uri(cls, jwks_uri, user_agent=USER_AGENT):
141
+ reader = jwt.PyJWKClient(
142
+ jwks_uri, cache_jwk_set=False, cache_keys=False, headers={'User-Agent': user_agent}, timeout=KEY_READ_TIMEOUT_SECONDS
143
+ )
144
+ return cls(lambda: reader.get_jwk_set(refresh=True))
145
+
146
+ def get_signing_key_from_jwt(self, token):
147
+ """The held key the token's `kid` names, read again first where it is unknown or the keys are old.
148
+
149
+ `jwt.PyJWKClientConnectionError` means no key has ever been read.
150
+ """
151
+ kid = jwt.get_unverified_header(token).get('kid')
152
+ with self.mutex:
153
+ if kid not in self.keys or self.clock() - self.read_at >= KEY_SET_LIFESPAN_SECONDS:
154
+ self.read()
155
+ key = self.keys.get(kid)
156
+ if key is None:
157
+ raise jwt.PyJWKClientError(f'the issuer publishes no signing key {kid!r}')
158
+ return key
159
+
160
+ def read(self):
161
+ now = self.clock()
162
+ if self.keys and self.tried_at is not None and now - self.tried_at < KEY_REREAD_GAP_SECONDS:
163
+ return
164
+ self.tried_at = now
165
+ try:
166
+ key_set = self.read_key_set()
167
+ except jwt.PyJWTError as error:
168
+ if self.keys:
169
+ return
170
+ raise jwt.PyJWKClientConnectionError(f'the issuer published no usable key: {error}') from error
171
+ self.keys = {key.key_id: key for key in key_set.keys if key.key_id and key.public_key_use in ('sig', None)}
172
+ self.read_at = now
173
+
174
+
175
+ class Verifier:
176
+ """Judges a bearer token against one issuer and this product's two client prefixes.
177
+
178
+ `person_prefix` starts every person's CLI client id, such as `acme-cli-`.
179
+ `service_prefix` starts every service client's, such as `acme-svc-`, and
180
+ empty refuses every service token. `keys` answers
181
+ `get_signing_key_from_jwt`, as `HeldKeys` and `jwt.PyJWKClient` both do.
182
+ """
183
+
184
+ def __init__(self, issuer, person_prefix, keys, clock=time.time, service_prefix=''):
185
+ check_prefixes(person_prefix, service_prefix)
186
+ self.issuer = issuer
187
+ self.person_prefix = person_prefix
188
+ self.service_prefix = service_prefix
189
+ self.keys = keys
190
+ self.clock = clock
191
+
192
+ @classmethod
193
+ def from_discovery(cls, issuer, person_prefix, client=None, service_prefix='', user_agent=USER_AGENT):
194
+ jwks_uri = discover_jwks_uri(issuer, client=client, user_agent=user_agent)
195
+ return cls(issuer, person_prefix, HeldKeys.from_jwks_uri(jwks_uri, user_agent), service_prefix=service_prefix)
196
+
197
+ def kind_of(self, client_id):
198
+ """'person' or 'service' by the client's prefix, or '' for a client this API does not know."""
199
+ if not isinstance(client_id, str):
200
+ return ''
201
+ if client_id.startswith(self.person_prefix):
202
+ return 'person'
203
+ if self.service_prefix and client_id.startswith(self.service_prefix):
204
+ return 'service'
205
+ return ''
206
+
207
+ def verify(self, token):
208
+ """The caller a token establishes, or `Rejected` naming the check it failed."""
209
+ try:
210
+ header = jwt.get_unverified_header(token)
211
+ except jwt.PyJWTError as error:
212
+ raise Rejected(f'header unreadable: {error}') from error
213
+ token_type = str(header.get('typ', ''))
214
+ if token_type.lower() != ACCESS_TOKEN_TYPE:
215
+ raise Rejected(f'type {token_type!r} is not {ACCESS_TOKEN_TYPE}')
216
+
217
+ try:
218
+ key = self.keys.get_signing_key_from_jwt(token)
219
+ except jwt.PyJWKClientConnectionError as error:
220
+ raise IssuerUnreachable(f'JWKS fetch failed: {error}') from error
221
+ except jwt.PyJWTError as error:
222
+ raise Rejected(f'no signing key: {error}') from error
223
+
224
+ # PyJWT's own claim checks are switched off so each runs below in the
225
+ # order above. Its order would report an expired id_token as expired
226
+ # rather than as the wrong kind of token.
227
+ try:
228
+ claims = jwt.decode(
229
+ token,
230
+ key.key,
231
+ algorithms=SIGNING_ALGORITHMS,
232
+ options={'verify_exp': False, 'verify_iss': False, 'verify_aud': False, 'verify_iat': False, 'verify_nbf': False},
233
+ )
234
+ except jwt.PyJWTError as error:
235
+ raise Rejected(f'signature: {error}') from error
236
+
237
+ if claims.get('iss') != self.issuer:
238
+ raise Rejected(f'issuer {claims.get("iss")!r} is not {self.issuer!r}')
239
+ client_id = claims.get('client_id')
240
+ kind = self.kind_of(client_id)
241
+ if not kind:
242
+ known = ' or '.join(f'{prefix}*' for prefix in (self.person_prefix, self.service_prefix) if prefix)
243
+ raise Rejected(f'client {client_id!r} is not a {known} client')
244
+ expires = claims.get('exp')
245
+ if not isinstance(expires, int | float) or expires <= self.clock():
246
+ raise Rejected(f'expired or no expiry: exp={expires!r}')
247
+ granted = claims.get('scp')
248
+ scopes = frozenset(scope for scope in granted if isinstance(scope, str)) if isinstance(granted, list) else frozenset()
249
+ if kind == 'service':
250
+ if not scopes:
251
+ raise Rejected(f'service client {client_id} holds no scope')
252
+ return Caller(subject='', client_id=client_id, scopes=scopes, service=True)
253
+ subject = claims.get('sub')
254
+ if not isinstance(subject, str) or not subject:
255
+ raise Rejected('no subject')
256
+ name = claims.get('preferred_username')
257
+ return Caller(subject=subject, client_id=client_id, scopes=scopes, name=name if isinstance(name, str) else '')
258
+
259
+
260
+ def check_prefixes(person, service):
261
+ """Refuse a prefix `person_prefix` or `service_prefix` could not have built, or two that overlap.
262
+
263
+ A bare product name would admit every client id it starts: `acme` admits
264
+ `acmecorp-cli-desk`.
265
+ """
266
+ if not person.endswith('-cli-') or person == '-cli-':
267
+ raise ValueError(f'person prefix {person!r} is not person_prefix(product) for any product')
268
+ if service and (not service.endswith('-svc-') or service == '-svc-'):
269
+ raise ValueError(f'service prefix {service!r} is not service_prefix(product) for any product')
270
+ if service and (person.startswith(service) or service.startswith(person)):
271
+ raise ValueError(f'client prefixes {person!r} and {service!r} overlap')
272
+
273
+
274
+ class LazyVerifier:
275
+ """A `Verifier` built on first use, and rebuilt after a failed build. goapiauth's `Connecting`.
276
+
277
+ Discovery needs the issuer up. Building at startup would make an issuer
278
+ outage stop the API from starting, and its health route would go with it. A
279
+ failed build is not remembered, so the request after the issuer returns
280
+ succeeds.
281
+ """
282
+
283
+ def __init__(self, issuer, person_prefix, build=Verifier.from_discovery, service_prefix='', user_agent=USER_AGENT):
284
+ check_prefixes(person_prefix, service_prefix)
285
+ self.issuer = issuer
286
+ self.person_prefix = person_prefix
287
+ self.service_prefix = service_prefix
288
+ self.user_agent = user_agent
289
+ self.build = build
290
+ self.verifier = None
291
+
292
+ def verify(self, token):
293
+ if self.verifier is None:
294
+ self.verifier = self.build(self.issuer, self.person_prefix, service_prefix=self.service_prefix, user_agent=self.user_agent)
295
+ return self.verifier.verify(token)
@@ -0,0 +1,117 @@
1
+ Metadata-Version: 2.5
2
+ Name: pyapiauth
3
+ Version: 0.1.0
4
+ Summary: Verify an OIDC provider's RFC 9068 access tokens inside a Python web API, with FastAPI dependencies
5
+ Project-URL: Repository, https://github.com/datapointchris/pyapiauth
6
+ Project-URL: Issues, https://github.com/datapointchris/pyapiauth/issues
7
+ Project-URL: Changelog, https://github.com/datapointchris/pyapiauth/releases
8
+ Author-email: Chris Birch <datapointchris@gmail.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: bearer,fastapi,jwt,oauth2,oidc,rfc9068
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Framework :: FastAPI
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Programming Language :: Python :: 3 :: Only
16
+ Classifier: Topic :: Internet :: WWW/HTTP
17
+ Classifier: Topic :: Security
18
+ Classifier: Topic :: Software Development :: Libraries
19
+ Classifier: Typing :: Typed
20
+ Requires-Python: >=3.11
21
+ Requires-Dist: httpx2>=2.9.1
22
+ Requires-Dist: pyjwt[crypto]>=2.10
23
+ Provides-Extra: fastapi
24
+ Requires-Dist: fastapi>=0.120; extra == 'fastapi'
25
+ Description-Content-Type: text/markdown
26
+
27
+ # pyapiauth
28
+
29
+ Verify an OIDC provider's RFC 9068 access tokens inside a Python web API. A CLI
30
+ logs in with the device grant, a service with the client-credentials grant, and
31
+ each sends the provider's access token as a bearer. pyapiauth checks it against
32
+ the issuer's published keys and says who is asking: a person, or a service
33
+ client limited to its scopes.
34
+
35
+ It is the Python sibling of
36
+ [goapiauth](https://github.com/datapointchris/goapiauth). Its client-side
37
+ counterpart is [pyclilogin](https://github.com/datapointchris/pyclilogin),
38
+ which obtains the tokens this package verifies.
39
+
40
+ ## Install
41
+
42
+ ```bash
43
+ uv add pyapiauth # the verifier
44
+ uv add "pyapiauth[fastapi]" # plus the FastAPI dependencies
45
+ ```
46
+
47
+ ## Starting from scratch
48
+
49
+ An API for a product called `acme` trusts one issuer and two client prefixes:
50
+ `acme-cli-` for each person's machine, and `acme-svc-` for each service.
51
+
52
+ ```python
53
+ from fastapi import APIRouter, Depends
54
+ from pyapiauth import LazyVerifier, person_prefix, service_prefix
55
+ from pyapiauth.fastapi_dependencies import person_dependency, person_or_scoped_service_dependency
56
+
57
+ verifier = LazyVerifier('https://auth.example.com', person_prefix('acme'), service_prefix=service_prefix('acme'))
58
+
59
+ people = APIRouter(dependencies=[Depends(person_dependency(verifier))])
60
+ readers = APIRouter(dependencies=[Depends(person_or_scoped_service_dependency(verifier, 'acme.read'))])
61
+ ```
62
+
63
+ Each dependency puts the verified `Caller` on `request.state.caller`. Without
64
+ FastAPI, `verifier.verify(token)` returns the `Caller` or raises.
65
+
66
+ `LazyVerifier` reads the issuer's discovery document on the first request,
67
+ rather than at startup. An issuer that is down then cannot stop the API from
68
+ starting, and a failed read is tried again on the next request.
69
+
70
+ ## A token passes six checks, in this order
71
+
72
+ 1. The header's `typ` is `at+jwt`.
73
+ 2. The signature verifies against a key from the issuer's JWKS.
74
+ 3. `iss` is the issuer the API trusts.
75
+ 4. `client_id` starts with the person prefix or the service prefix.
76
+ 5. `exp` is in the future.
77
+ 6. A person's token carries a `sub`. A service's token carries a non-empty
78
+ `scp`.
79
+
80
+ The type check comes first because an id_token from the same issuer is signed
81
+ by the same key, and would otherwise pass every later step. The prefix decides
82
+ whether a token is a person's or a service's, never the absence of `sub`. The
83
+ audience claim is not checked, because some providers leave it empty on a
84
+ device grant's token. The client prefix keeps another product's tokens out.
85
+
86
+ A prefix no product name builds is refused when the verifier is built. A bare
87
+ `acme` would admit `acmecorp-cli-desk`. An empty service prefix refuses every
88
+ service token.
89
+
90
+ ## A refusal tells the caller nothing, and an outage is not a refusal
91
+
92
+ | Outcome | Exception | FastAPI answer |
93
+ | --- | --- | --- |
94
+ | No bearer, or one that fails a check | `Rejected` | 401 `Invalid credentials`, with a `WWW-Authenticate` challenge |
95
+ | The issuer cannot be read | `IssuerUnreachable` | 503 `Identity provider unavailable` |
96
+ | A service on a route no scope of its admits | — | 403 |
97
+
98
+ A caller probing with forged tokens learns nothing about which check failed.
99
+ The reason goes to the `pyapiauth.fastapi_dependencies` logger, as
100
+ `bearer_rejected` at WARNING, `issuer_unreachable` at ERROR, and
101
+ `bearer_missing` and `service_refused` at INFO. A 503 never sends anyone to log
102
+ in again, because a login cannot fix a network fault.
103
+
104
+ ## The keys outlive an outage of the issuer
105
+
106
+ `HeldKeys` reads the key set once and holds it. A token naming a `kid` no held
107
+ key carries reads it again, which finds a key the provider has just rotated in.
108
+ Those reads are at least 30 seconds apart, so a stream of forged tokens costs
109
+ the provider one read per gap. Keys older than five minutes are read again too,
110
+ which bounds how long a key the provider stopped publishing still verifies. A
111
+ read that fails keeps the held keys, so an outage of the issuer is not an
112
+ outage of the API.
113
+
114
+ ## What it deliberately does not do
115
+
116
+ It does not issue, refresh or store tokens; pyclilogin does that on the client.
117
+ It verifies RS256 alone, and does not introspect opaque tokens.
@@ -0,0 +1,9 @@
1
+ pyapiauth/__init__.py,sha256=iiY1ZwPp7KRlP9YMRVwpQoza8XUbsvs4nPo2pjxuyLU,1530
2
+ pyapiauth/errors.py,sha256=tJWA81OWU8zYEcqKS-QGgjHhd936VQk3mhJlc_zCh3E,609
3
+ pyapiauth/fastapi_dependencies.py,sha256=8YTPpwr0mFK5mUqn17iALP8xlkGtAsiw4IKaC0ezQ-c,3898
4
+ pyapiauth/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ pyapiauth/verifier.py,sha256=z_0IVjii24aU0HqYENZ-bK2eOJlJ9BHjSyahD7yxuCs,12743
6
+ pyapiauth-0.1.0.dist-info/METADATA,sha256=peYNOiqRoDXnouZdvwIAjR5fZPzX8SUpbqg4Qx2XZgY,5212
7
+ pyapiauth-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
8
+ pyapiauth-0.1.0.dist-info/licenses/LICENSE,sha256=GAtoA41_GNRzmpuNDCm36J0OQXWmAEkUJXwqla22KLo,1068
9
+ pyapiauth-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Chris Birch
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.