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 +44 -0
- pyapiauth/errors.py +20 -0
- pyapiauth/fastapi_dependencies.py +92 -0
- pyapiauth/py.typed +0 -0
- pyapiauth/verifier.py +295 -0
- pyapiauth-0.1.0.dist-info/METADATA +117 -0
- pyapiauth-0.1.0.dist-info/RECORD +9 -0
- pyapiauth-0.1.0.dist-info/WHEEL +4 -0
- pyapiauth-0.1.0.dist-info/licenses/LICENSE +21 -0
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,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.
|