authgate-client 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.
- authgate/__init__.py +24 -0
- authgate/errors.py +24 -0
- authgate/fastapi.py +68 -0
- authgate/identity.py +55 -0
- authgate/keys.py +167 -0
- authgate/py.typed +0 -0
- authgate/verifier.py +153 -0
- authgate_client-0.1.0.dist-info/METADATA +188 -0
- authgate_client-0.1.0.dist-info/RECORD +11 -0
- authgate_client-0.1.0.dist-info/WHEEL +4 -0
- authgate_client-0.1.0.dist-info/licenses/LICENSE +21 -0
authgate/__init__.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""authgate — verify AuthGate access tokens.
|
|
2
|
+
|
|
3
|
+
from authgate import AuthGate
|
|
4
|
+
|
|
5
|
+
auth = AuthGate(issuer="https://auth.example.com", audience="https://api.example.com")
|
|
6
|
+
caller = auth.verify(token)
|
|
7
|
+
caller.may("material", "update")
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from .errors import AuthGateError, ConfigurationError, InvalidTokenError, IssuerUnavailableError
|
|
11
|
+
from .identity import Identity
|
|
12
|
+
from .verifier import AuthGate
|
|
13
|
+
|
|
14
|
+
__version__ = "0.1.0"
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"AuthGate",
|
|
18
|
+
"AuthGateError",
|
|
19
|
+
"ConfigurationError",
|
|
20
|
+
"Identity",
|
|
21
|
+
"InvalidTokenError",
|
|
22
|
+
"IssuerUnavailableError",
|
|
23
|
+
"__version__",
|
|
24
|
+
]
|
authgate/errors.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""The errors this package defines.
|
|
2
|
+
|
|
3
|
+
The verifier is framework-free: it raises these, and `authgate.fastapi` maps them to status codes.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class AuthGateError(Exception):
|
|
8
|
+
"""Base for every error this package raises."""
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ConfigurationError(AuthGateError):
|
|
12
|
+
"""The verifier is set up wrong, or the issuer's discovery document contradicts the setup."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class InvalidTokenError(AuthGateError):
|
|
16
|
+
"""The caller's token is not acceptable here. Maps to 401."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class IssuerUnavailableError(AuthGateError):
|
|
20
|
+
"""The issuer's signing keys could not be fetched. Maps to 503.
|
|
21
|
+
|
|
22
|
+
Kept apart from InvalidTokenError on purpose. Reporting an outage as a 401 tells the client its
|
|
23
|
+
token is bad, and a well-behaved client responds by discarding it and signing the user out.
|
|
24
|
+
"""
|
authgate/fastapi.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""FastAPI integration — the only module in the package that imports a web framework.
|
|
2
|
+
|
|
3
|
+
Reached through `AuthGate.identity` and `AuthGate.require`; there is nothing here to import directly.
|
|
4
|
+
|
|
5
|
+
No `from __future__ import annotations` in this module: FastAPI reads the dependency signatures
|
|
6
|
+
below at runtime, and `require`'s annotation refers to a closure variable that string annotations
|
|
7
|
+
could not resolve.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from typing import TYPE_CHECKING, Annotated
|
|
13
|
+
|
|
14
|
+
from fastapi import Depends, HTTPException, status
|
|
15
|
+
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
16
|
+
|
|
17
|
+
from .errors import InvalidTokenError, IssuerUnavailableError
|
|
18
|
+
from .identity import Identity
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from .verifier import AuthGate
|
|
22
|
+
|
|
23
|
+
log = logging.getLogger("authgate")
|
|
24
|
+
|
|
25
|
+
# auto_error=False so a missing token gets the same 401 and WWW-Authenticate header as a bad one,
|
|
26
|
+
# rather than FastAPI's own response. The scheme still shows up in the OpenAPI document.
|
|
27
|
+
_bearer = HTTPBearer(auto_error=False)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def identity_dependency(auth: "AuthGate") -> Callable[..., Identity]:
|
|
31
|
+
# A plain `def`, not `async def`: FastAPI runs it in its threadpool, so the rare key fetch
|
|
32
|
+
# never blocks the event loop.
|
|
33
|
+
def identity(credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(_bearer)]) -> Identity:
|
|
34
|
+
if credentials is None:
|
|
35
|
+
raise HTTPException(
|
|
36
|
+
status.HTTP_401_UNAUTHORIZED, "missing bearer token", headers={"WWW-Authenticate": "Bearer"}
|
|
37
|
+
)
|
|
38
|
+
try:
|
|
39
|
+
# Stripped because FastAPI versions disagree on whether they do it: RFC 6750 allows more
|
|
40
|
+
# than one space after "Bearer", and only newer releases trim it off.
|
|
41
|
+
return auth.verify(credentials.credentials.strip())
|
|
42
|
+
except InvalidTokenError as exc:
|
|
43
|
+
raise HTTPException(
|
|
44
|
+
status.HTTP_401_UNAUTHORIZED, str(exc), headers={"WWW-Authenticate": 'Bearer error="invalid_token"'}
|
|
45
|
+
) from exc
|
|
46
|
+
except IssuerUnavailableError as exc:
|
|
47
|
+
# The detail — internal host names, network errors, the caller's kid — is for the log, not
|
|
48
|
+
# for an unauthenticated caller. Truncated, because part of it is caller-supplied.
|
|
49
|
+
log.warning("Answering 503, the token issuer is unavailable: %.500s", exc)
|
|
50
|
+
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, "the token issuer is unavailable") from exc
|
|
51
|
+
|
|
52
|
+
return identity
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def require_dependency(auth: "AuthGate", resource: str, action: str) -> Callable[..., Identity]:
|
|
56
|
+
identity = auth.identity
|
|
57
|
+
|
|
58
|
+
def require(caller: Annotated[Identity, Depends(identity)]) -> Identity:
|
|
59
|
+
if not caller.may(resource, action):
|
|
60
|
+
raise HTTPException(
|
|
61
|
+
status.HTTP_403_FORBIDDEN,
|
|
62
|
+
f"missing permission: {action} on {resource}",
|
|
63
|
+
# RFC 6750 §3.1, and what Spring's resource server answers the same denial with.
|
|
64
|
+
headers={"WWW-Authenticate": 'Bearer error="insufficient_scope"'},
|
|
65
|
+
)
|
|
66
|
+
return caller
|
|
67
|
+
|
|
68
|
+
return require
|
authgate/identity.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""The caller, as an AuthGate access token describes them."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from dataclasses import KW_ONLY, dataclass, field
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True, slots=True)
|
|
11
|
+
class Identity:
|
|
12
|
+
"""A typed view over a verified token, so handlers are not digging through a claim dict.
|
|
13
|
+
|
|
14
|
+
Mirrors `Identity` in the Java starter, so a permission check reads the same in both languages.
|
|
15
|
+
|
|
16
|
+
`account_id` is AuthGate's account id (the `sub` claim), not the external provider's subject:
|
|
17
|
+
a service never needs to know which identity provider someone signed in through.
|
|
18
|
+
|
|
19
|
+
`claims` takes no part in equality or hashing: two tokens for the same caller differ in `jti`,
|
|
20
|
+
`iat` and `exp`, and still describe the same caller.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
account_id: str
|
|
24
|
+
# Everything after the account id is keyword-only, so a field added later cannot silently shift
|
|
25
|
+
# a positional call in someone's code.
|
|
26
|
+
_: KW_ONLY
|
|
27
|
+
tenant_id: str | None = None
|
|
28
|
+
email: str | None = None
|
|
29
|
+
superuser: bool = False
|
|
30
|
+
permissions: Mapping[str, frozenset[str]] = field(default_factory=dict, hash=False)
|
|
31
|
+
claims: Mapping[str, Any] = field(default_factory=dict, repr=False, compare=False)
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def from_claims(cls, claims: Mapping[str, Any]) -> Identity:
|
|
35
|
+
"""Build an identity from a payload that has already been verified. `sub` must be present."""
|
|
36
|
+
permissions: dict[str, frozenset[str]] = {}
|
|
37
|
+
perms = claims.get("perms")
|
|
38
|
+
if isinstance(perms, Mapping):
|
|
39
|
+
for resource, actions in perms.items():
|
|
40
|
+
# A malformed entry grants nothing rather than failing the whole request — the Java
|
|
41
|
+
# starter treats one the same way.
|
|
42
|
+
permissions[resource] = frozenset(map(str, actions)) if isinstance(actions, list) else frozenset()
|
|
43
|
+
return cls(
|
|
44
|
+
account_id=str(claims["sub"]),
|
|
45
|
+
tenant_id=claims.get("tenant"),
|
|
46
|
+
email=claims.get("email"),
|
|
47
|
+
# Only a literal JSON true. A string "true" is a malformed token, not a superuser.
|
|
48
|
+
superuser=claims.get("superuser") is True,
|
|
49
|
+
permissions=permissions,
|
|
50
|
+
claims=dict(claims),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
def may(self, resource: str, action: str) -> bool:
|
|
54
|
+
"""Whether this caller may take `action` on `resource`. Superusers pass everything."""
|
|
55
|
+
return self.superuser or action in self.permissions.get(resource, frozenset())
|
authgate/keys.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Fetches and caches an issuer's signing keys."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
import urllib.error
|
|
10
|
+
import urllib.request
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import jwt
|
|
15
|
+
|
|
16
|
+
from .errors import AuthGateError, ConfigurationError, IssuerUnavailableError
|
|
17
|
+
|
|
18
|
+
log = logging.getLogger("authgate")
|
|
19
|
+
|
|
20
|
+
_TIMEOUT_SECONDS = 5
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class KeyCache:
|
|
24
|
+
"""The issuer's RS256 public keys, indexed by `kid`.
|
|
25
|
+
|
|
26
|
+
Discovery is read once; the JWKS is refreshed when it goes stale or when a token names a key we
|
|
27
|
+
have not seen, which is how a key rotation reaches a running verifier.
|
|
28
|
+
|
|
29
|
+
Every fetch *attempt*, successful or not, is spaced at least `min_interval` seconds from the
|
|
30
|
+
last. Without that, a stream of tokens with made-up `kid`s would turn each request into a
|
|
31
|
+
request to the issuer. The defaults — 300 s freshness, 30 s between attempts — are the ones
|
|
32
|
+
Nimbus uses, which is what the Java starter verifies with.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
issuer: str,
|
|
38
|
+
*,
|
|
39
|
+
ttl: float = 300,
|
|
40
|
+
min_interval: float = 30,
|
|
41
|
+
clock: Callable[[], float] = time.monotonic,
|
|
42
|
+
) -> None:
|
|
43
|
+
self._issuer = issuer
|
|
44
|
+
self._ttl = ttl
|
|
45
|
+
self._min_interval = min_interval
|
|
46
|
+
self._clock = clock
|
|
47
|
+
self._lock = threading.Lock()
|
|
48
|
+
self._keys: dict[str, Any] | None = None
|
|
49
|
+
self._fetched_at = 0.0
|
|
50
|
+
self._attempted_at: float | None = None
|
|
51
|
+
self._failure: AuthGateError | None = None
|
|
52
|
+
self._refresh_failed = False
|
|
53
|
+
self._jwks_uri: str | None = None
|
|
54
|
+
self.issuer: str | None = None
|
|
55
|
+
"""The `iss` value tokens must carry, exactly as the discovery document states it."""
|
|
56
|
+
|
|
57
|
+
def get(self, kid: str) -> Any | None:
|
|
58
|
+
"""The key for `kid`, or None if the issuer does not publish one.
|
|
59
|
+
|
|
60
|
+
Raises IssuerUnavailableError (or ConfigurationError) while no keys have ever been fetched,
|
|
61
|
+
and IssuerUnavailableError for an unknown `kid` while the last refresh failed — the token
|
|
62
|
+
may be perfectly valid, and a 401 would tell its holder otherwise. Once keys are cached, a
|
|
63
|
+
failed refresh keeps serving them.
|
|
64
|
+
"""
|
|
65
|
+
keys = self._keys
|
|
66
|
+
cached = keys is not None and kid in keys
|
|
67
|
+
if cached and not self._stale():
|
|
68
|
+
return keys[kid]
|
|
69
|
+
|
|
70
|
+
# Without a usable key, wait for whoever is fetching. With one, never queue behind a slow
|
|
71
|
+
# issuer: the cached key is the best answer until the refresh lands.
|
|
72
|
+
if not self._lock.acquire(blocking=not cached):
|
|
73
|
+
return keys[kid]
|
|
74
|
+
try:
|
|
75
|
+
keys = self._keys
|
|
76
|
+
wanted = keys is None or kid not in keys or self._stale()
|
|
77
|
+
# The rate limit doubles as the re-check: a request that queued on the lock behind one
|
|
78
|
+
# that just fetched finds the attempt too recent and uses what it brought back.
|
|
79
|
+
if wanted and self._may_attempt():
|
|
80
|
+
self._refresh()
|
|
81
|
+
keys, failure, refresh_failed = self._keys, self._failure, self._refresh_failed
|
|
82
|
+
finally:
|
|
83
|
+
self._lock.release()
|
|
84
|
+
|
|
85
|
+
if keys is None:
|
|
86
|
+
raise type(failure)(*failure.args) if failure else IssuerUnavailableError("no keys fetched yet")
|
|
87
|
+
if kid not in keys and refresh_failed:
|
|
88
|
+
# Until a refresh succeeds, not every request that names an unknown kid can make its own
|
|
89
|
+
# attempt, so the answer holds for the whole interval, not just the one that tried.
|
|
90
|
+
raise IssuerUnavailableError(f"could not refresh signing keys from {self._issuer} to look for {kid!r}")
|
|
91
|
+
return keys.get(kid)
|
|
92
|
+
|
|
93
|
+
def _stale(self) -> bool:
|
|
94
|
+
return self._clock() - self._fetched_at >= self._ttl
|
|
95
|
+
|
|
96
|
+
def _may_attempt(self) -> bool:
|
|
97
|
+
return self._attempted_at is None or self._clock() - self._attempted_at >= self._min_interval
|
|
98
|
+
|
|
99
|
+
def _refresh(self) -> None:
|
|
100
|
+
"""Fetch the JWKS. A failure with keys cached keeps them in use and marks the refresh failed."""
|
|
101
|
+
self._attempted_at = self._clock()
|
|
102
|
+
try:
|
|
103
|
+
if self._jwks_uri is None:
|
|
104
|
+
self._discover()
|
|
105
|
+
keys = _usable_keys(_fetch_json(self._jwks_uri))
|
|
106
|
+
except ConfigurationError as exc:
|
|
107
|
+
# Stored as a fresh instance so the cache does not keep the traceback's frames alive.
|
|
108
|
+
self._failure = ConfigurationError(*exc.args)
|
|
109
|
+
raise
|
|
110
|
+
except Exception as exc: # whatever went wrong fetching or parsing, the issuer is unavailable to us
|
|
111
|
+
if self._keys is None:
|
|
112
|
+
self._failure = IssuerUnavailableError(f"could not fetch signing keys from {self._issuer}: {exc}")
|
|
113
|
+
raise IssuerUnavailableError(*self._failure.args) from exc
|
|
114
|
+
log.warning("Could not refresh signing keys from %s, keeping cached keys: %r", self._issuer, exc)
|
|
115
|
+
self._refresh_failed = True
|
|
116
|
+
return
|
|
117
|
+
|
|
118
|
+
self._keys = keys
|
|
119
|
+
self._fetched_at = self._attempted_at
|
|
120
|
+
self._failure = None
|
|
121
|
+
self._refresh_failed = False
|
|
122
|
+
|
|
123
|
+
def _discover(self) -> None:
|
|
124
|
+
url = f"{self._issuer}/.well-known/openid-configuration"
|
|
125
|
+
document = _fetch_json(url)
|
|
126
|
+
stated = document.get("issuer") if isinstance(document, dict) else None
|
|
127
|
+
# A redirect or a misconfigured proxy must not be able to point us at another issuer's keys.
|
|
128
|
+
if not isinstance(stated, str) or stated.rstrip("/") != self._issuer:
|
|
129
|
+
raise ConfigurationError(f"{url} names issuer {stated!r}, expected {self._issuer!r}")
|
|
130
|
+
jwks_uri = document.get("jwks_uri")
|
|
131
|
+
if not isinstance(jwks_uri, str):
|
|
132
|
+
raise ConfigurationError(f"{url} has no jwks_uri")
|
|
133
|
+
self._jwks_uri = jwks_uri
|
|
134
|
+
self.issuer = stated
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _usable_keys(document: Any) -> dict[str, Any]:
|
|
138
|
+
"""The RS256 signing keys in a JWKS, by `kid`.
|
|
139
|
+
|
|
140
|
+
Anything else is dropped here rather than failing later: an EC key under a `kid` a token names
|
|
141
|
+
would otherwise reach the RS256 verifier and fail as a 500 rather than a 401. If a `kid`
|
|
142
|
+
repeats, the first entry wins, so an unusable duplicate cannot displace a good key.
|
|
143
|
+
"""
|
|
144
|
+
keys: dict[str, Any] = {}
|
|
145
|
+
for key in jwt.PyJWKSet.from_dict(document).keys:
|
|
146
|
+
if (
|
|
147
|
+
isinstance(key.key_id, str)
|
|
148
|
+
and key.key_type == "RSA"
|
|
149
|
+
and key.algorithm_name == "RS256"
|
|
150
|
+
and key.public_key_use in (None, "sig")
|
|
151
|
+
):
|
|
152
|
+
keys.setdefault(key.key_id, key.key)
|
|
153
|
+
if not keys:
|
|
154
|
+
# Treated like any other failed fetch: an issuer publishing nothing usable is broken, and
|
|
155
|
+
# adopting an empty set would reject every token.
|
|
156
|
+
raise ValueError("the JWKS has no RS256 signing key")
|
|
157
|
+
return keys
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _fetch_json(url: str) -> Any:
|
|
161
|
+
request = urllib.request.Request(url, headers={"Accept": "application/json"})
|
|
162
|
+
try:
|
|
163
|
+
with urllib.request.urlopen(request, timeout=_TIMEOUT_SECONDS) as response:
|
|
164
|
+
return json.load(response)
|
|
165
|
+
except urllib.error.HTTPError as exc:
|
|
166
|
+
exc.close() # an error status arrives as an exception that still holds the open response
|
|
167
|
+
raise
|
authgate/py.typed
ADDED
|
File without changes
|
authgate/verifier.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Verifies AuthGate access tokens."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from functools import cached_property
|
|
7
|
+
from urllib.parse import urlsplit
|
|
8
|
+
|
|
9
|
+
import jwt
|
|
10
|
+
|
|
11
|
+
from .errors import ConfigurationError, InvalidTokenError, IssuerUnavailableError
|
|
12
|
+
from .identity import Identity
|
|
13
|
+
from .keys import KeyCache
|
|
14
|
+
|
|
15
|
+
# Pinned, never read from the token header — that is what closes `alg: none` and the HMAC
|
|
16
|
+
# confusion attack, where an RSA public key is presented as an HMAC secret.
|
|
17
|
+
_ALGORITHMS = ["RS256"]
|
|
18
|
+
_REQUIRED_CLAIMS = ["exp", "iat", "iss", "aud", "sub", "jti"]
|
|
19
|
+
_LOCAL_HOSTS = {"localhost", "127.0.0.1", "::1"}
|
|
20
|
+
# Clock skew beyond a few minutes is a broken clock, not skew — and against tokens that live for
|
|
21
|
+
# fifteen, a larger allowance would quietly extend every one of them.
|
|
22
|
+
_MAX_LEEWAY_SECONDS = 300
|
|
23
|
+
# PyJWT before 2.15 lets a deeply nested header escape json.loads as a RecursionError. Any caller can
|
|
24
|
+
# send one, so it has to be a 401 rather than a 500 whatever PyJWT version is installed.
|
|
25
|
+
_PARSE_ERRORS = (jwt.PyJWTError, RecursionError)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AuthGate:
|
|
29
|
+
"""Verifies tokens issued by one AuthGate server for one audience.
|
|
30
|
+
|
|
31
|
+
Construction does no I/O. The issuer's discovery document and keys are fetched on first use and
|
|
32
|
+
cached, so verifying a token is normally a signature check and nothing else.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, issuer: str, audience: str, *, leeway: float = 60) -> None:
|
|
36
|
+
if not isinstance(issuer, str) or not issuer.strip():
|
|
37
|
+
raise ConfigurationError("issuer must be set")
|
|
38
|
+
if not isinstance(audience, str) or not audience.strip():
|
|
39
|
+
raise ConfigurationError(
|
|
40
|
+
"audience must be set, as one string — without it this service would accept any token the issuer "
|
|
41
|
+
"ever minted, including tokens meant for other services"
|
|
42
|
+
)
|
|
43
|
+
issuer = issuer.strip().rstrip("/")
|
|
44
|
+
audience = audience.strip()
|
|
45
|
+
try:
|
|
46
|
+
url = urlsplit(issuer)
|
|
47
|
+
_ = url.port # raises ValueError for a port that is not a number in range
|
|
48
|
+
except ValueError as exc:
|
|
49
|
+
raise ConfigurationError(f"issuer is not a valid URL: {issuer!r}") from exc
|
|
50
|
+
# Keys fetched over plain HTTP can be swapped in transit. Localhost is exempt because the
|
|
51
|
+
# server's own development issuer is http://localhost:8080.
|
|
52
|
+
if url.scheme != "https" and not (url.scheme == "http" and url.hostname in _LOCAL_HOSTS):
|
|
53
|
+
raise ConfigurationError(f"issuer must be an https URL (plain http only for localhost), got {issuer!r}")
|
|
54
|
+
# An OIDC issuer is scheme://host[:port][/path]. Anything more would be spliced into the
|
|
55
|
+
# discovery URL, and fail as a permanent outage rather than here, at startup.
|
|
56
|
+
if (
|
|
57
|
+
not url.hostname
|
|
58
|
+
or "@" in url.netloc
|
|
59
|
+
or "?" in issuer
|
|
60
|
+
or "#" in issuer
|
|
61
|
+
or any(c.isspace() or not c.isprintable() for c in issuer)
|
|
62
|
+
):
|
|
63
|
+
raise ConfigurationError(f"issuer must be scheme://host[:port][/path], got {issuer!r}")
|
|
64
|
+
# NaN or infinity would switch expiry checking off entirely; a string would fail every request.
|
|
65
|
+
if isinstance(leeway, bool) or not isinstance(leeway, (int, float)) or not 0 <= leeway <= _MAX_LEEWAY_SECONDS:
|
|
66
|
+
raise ConfigurationError(f"leeway must be between 0 and {_MAX_LEEWAY_SECONDS} seconds, got {leeway!r}")
|
|
67
|
+
|
|
68
|
+
self._issuer = issuer
|
|
69
|
+
self._audience = audience
|
|
70
|
+
self._leeway = leeway
|
|
71
|
+
self._keys = KeyCache(issuer)
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def issuer(self) -> str:
|
|
75
|
+
"""The issuer as configured, trailing slash dropped.
|
|
76
|
+
|
|
77
|
+
Tokens must carry the discovery document's `issuer` exactly, which is normally this same
|
|
78
|
+
value. Read-only: the key cache is bound to it at construction.
|
|
79
|
+
"""
|
|
80
|
+
return self._issuer
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def audience(self) -> str:
|
|
84
|
+
"""The `aud` value tokens must name. Read-only, like the issuer."""
|
|
85
|
+
return self._audience
|
|
86
|
+
|
|
87
|
+
def verify(self, token: str) -> Identity:
|
|
88
|
+
"""Verify `token` and return the caller it describes.
|
|
89
|
+
|
|
90
|
+
Raises InvalidTokenError for anything wrong with the token, and IssuerUnavailableError when
|
|
91
|
+
the issuer's keys cannot be fetched — including when the key a token names could not be
|
|
92
|
+
looked up because the issuer is unreachable. Raises ConfigurationError if, on first use, the
|
|
93
|
+
issuer's discovery document turns out to name a different issuer.
|
|
94
|
+
"""
|
|
95
|
+
try:
|
|
96
|
+
header = jwt.get_unverified_header(token)
|
|
97
|
+
except _PARSE_ERRORS as exc:
|
|
98
|
+
raise InvalidTokenError(f"malformed token: {exc}") from exc
|
|
99
|
+
# Checked before any key lookup, so a token that can never verify costs the issuer nothing and
|
|
100
|
+
# is refused as invalid even while the issuer is down. jwt.decode enforces it again.
|
|
101
|
+
if header.get("alg") not in _ALGORITHMS:
|
|
102
|
+
raise InvalidTokenError(f"token alg {header.get('alg')!r} is not allowed, expected RS256")
|
|
103
|
+
kid = header.get("kid")
|
|
104
|
+
if not isinstance(kid, str):
|
|
105
|
+
raise InvalidTokenError("token has no kid header")
|
|
106
|
+
|
|
107
|
+
key = self._keys.get(kid)
|
|
108
|
+
if key is None:
|
|
109
|
+
raise InvalidTokenError(f"token is signed with an unknown key {kid!r}")
|
|
110
|
+
issuer = self._keys.issuer
|
|
111
|
+
if issuer is None: # fail closed: PyJWT skips the iss check entirely when given no issuer
|
|
112
|
+
raise IssuerUnavailableError("the issuer's discovery document has not been read")
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
claims = jwt.decode(
|
|
116
|
+
token,
|
|
117
|
+
key=key,
|
|
118
|
+
algorithms=_ALGORITHMS,
|
|
119
|
+
audience=self.audience,
|
|
120
|
+
issuer=issuer,
|
|
121
|
+
leeway=self._leeway,
|
|
122
|
+
options={"require": _REQUIRED_CLAIMS},
|
|
123
|
+
)
|
|
124
|
+
except jwt.InvalidAudienceError as exc:
|
|
125
|
+
raise InvalidTokenError(f"token is not intended for this service (expected aud {self.audience!r})") from exc
|
|
126
|
+
except _PARSE_ERRORS as exc:
|
|
127
|
+
raise InvalidTokenError(str(exc)) from exc
|
|
128
|
+
return Identity.from_claims(claims)
|
|
129
|
+
|
|
130
|
+
@cached_property
|
|
131
|
+
def identity(self) -> Callable[..., Identity]:
|
|
132
|
+
"""FastAPI dependency resolving to the verified caller: `Depends(auth.identity)`.
|
|
133
|
+
|
|
134
|
+
Cached, so every route sees the same dependency object and FastAPI verifies once per
|
|
135
|
+
request even when `require()` is stacked on top of it.
|
|
136
|
+
"""
|
|
137
|
+
return _integration().identity_dependency(self)
|
|
138
|
+
|
|
139
|
+
def require(self, resource: str, action: str) -> Callable[..., Identity]:
|
|
140
|
+
"""FastAPI dependency that also demands a permission: `Depends(auth.require("material", "update"))`."""
|
|
141
|
+
return _integration().require_dependency(self, resource, action)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _integration():
|
|
145
|
+
try:
|
|
146
|
+
from . import fastapi
|
|
147
|
+
except ModuleNotFoundError as exc:
|
|
148
|
+
if exc.name != "fastapi":
|
|
149
|
+
raise
|
|
150
|
+
raise ModuleNotFoundError(
|
|
151
|
+
'the FastAPI integration needs the extra: pip install "authgate-client[fastapi]"', name="fastapi"
|
|
152
|
+
) from exc
|
|
153
|
+
return fastapi
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: authgate-client
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Verify AuthGate access tokens in Python, with an optional FastAPI integration.
|
|
5
|
+
Project-URL: Homepage, https://github.com/boskodjokic/authgate-python
|
|
6
|
+
Project-URL: Issues, https://github.com/boskodjokic/authgate-python/issues
|
|
7
|
+
Project-URL: Changelog, https://github.com/boskodjokic/authgate-python/blob/main/CHANGELOG.md
|
|
8
|
+
Project-URL: Server, https://github.com/boskodjokic/authgate
|
|
9
|
+
Author: Bosko Djokic
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: authentication,authgate,fastapi,jwks,jwt,oidc
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
|
+
Classifier: Topic :: Security
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Requires-Dist: pyjwt[crypto]>=2.13
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: fastapi>=0.100; extra == 'dev'
|
|
26
|
+
Requires-Dist: httpx2>=2.0; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
28
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
29
|
+
Provides-Extra: fastapi
|
|
30
|
+
Requires-Dist: fastapi>=0.100; extra == 'fastapi'
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# authgate-client
|
|
34
|
+
|
|
35
|
+
[](https://github.com/boskodjokic/authgate-python/actions/workflows/ci.yml)
|
|
36
|
+
[](https://pypi.org/project/authgate-client/)
|
|
37
|
+
[](https://pypi.org/project/authgate-client/)
|
|
38
|
+
[](https://github.com/boskodjokic/authgate-python/blob/main/LICENSE)
|
|
39
|
+
|
|
40
|
+
Verify [AuthGate](https://github.com/boskodjokic/authgate) access tokens in Python. Framework-free,
|
|
41
|
+
with one dependency — PyJWT and its `crypto` extra — and an optional FastAPI integration. Installed
|
|
42
|
+
as `authgate-client`, imported as `authgate`.
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from typing import Annotated
|
|
46
|
+
|
|
47
|
+
from fastapi import Depends, FastAPI
|
|
48
|
+
|
|
49
|
+
from authgate import AuthGate, Identity
|
|
50
|
+
|
|
51
|
+
auth = AuthGate(issuer="https://auth.example.com", audience="https://api.example.com")
|
|
52
|
+
app = FastAPI()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@app.get("/materials")
|
|
56
|
+
def list_materials(caller: Annotated[Identity, Depends(auth.identity)]):
|
|
57
|
+
return materials.for_tenant(caller.tenant_id)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@app.delete("/materials/{material_id}")
|
|
61
|
+
def delete_material(material_id: str, caller: Annotated[Identity, Depends(auth.require("material", "delete"))]):
|
|
62
|
+
materials.delete(material_id, by=caller.account_id)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
pip install authgate-client # the verifier
|
|
67
|
+
pip install "authgate-client[fastapi]" # plus the FastAPI dependencies
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## You may not need this
|
|
71
|
+
|
|
72
|
+
AuthGate publishes a standard `/.well-known/openid-configuration` and JWKS, so any JWT library that
|
|
73
|
+
can fetch a JWKS already verifies its tokens. This package is a convenience, the Python counterpart
|
|
74
|
+
of the server's Spring starter. What it adds is what a hand-written verifier usually gets wrong:
|
|
75
|
+
|
|
76
|
+
- **The audience is required.** A verifier that skips `aud` accepts every token the issuer ever
|
|
77
|
+
minted, including tokens meant for a different service. There is no default for "any".
|
|
78
|
+
- **A typed caller.** The `perms` claim arrives as `Identity.permissions`, and
|
|
79
|
+
`caller.may("material", "update")` applies the server's own rule, superuser included.
|
|
80
|
+
- **Key refresh that cannot be turned against the issuer.** Tokens naming an unknown `kid` trigger
|
|
81
|
+
at most one JWKS fetch every 30 seconds, however many arrive.
|
|
82
|
+
- **An outage is not a bad token.** If the issuer cannot be reached, the answer is 503, not 401 —
|
|
83
|
+
a 401 tells well-behaved clients to throw their token away and sign the user out.
|
|
84
|
+
|
|
85
|
+
## Without FastAPI
|
|
86
|
+
|
|
87
|
+
`verify` is plain synchronous code, so it works in Flask, Django, a worker or a script:
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
from authgate import AuthGate, InvalidTokenError, IssuerUnavailableError
|
|
91
|
+
|
|
92
|
+
auth = AuthGate(issuer="https://auth.example.com", audience="https://api.example.com")
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
caller = auth.verify(token)
|
|
96
|
+
except InvalidTokenError:
|
|
97
|
+
... # 401
|
|
98
|
+
except IssuerUnavailableError:
|
|
99
|
+
... # 503
|
|
100
|
+
|
|
101
|
+
if not caller.may("material", "update"):
|
|
102
|
+
... # 403
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
It is also why the FastAPI dependencies are plain `def`s: FastAPI runs them in its threadpool, so
|
|
106
|
+
the occasional key fetch never blocks the event loop. With the keys cached, verifying a token is
|
|
107
|
+
normally a signature check and nothing else.
|
|
108
|
+
|
|
109
|
+
## Testing an app that uses it
|
|
110
|
+
|
|
111
|
+
Override `auth.identity` to stand in a caller. Every `require()` builds on it, so the permission
|
|
112
|
+
checks still run against the identity you supply:
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
app.dependency_overrides[auth.identity] = lambda: Identity("acct-1", permissions={"material": frozenset({"read"})})
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
The dependencies are for HTTP routes, since they read the `Authorization` header. Use them with
|
|
119
|
+
`Depends`, not `Security(..., scopes=...)`: scopes make FastAPI treat each use as a separate
|
|
120
|
+
dependency, and the token would be verified once per use instead of once per request.
|
|
121
|
+
|
|
122
|
+
## What is checked
|
|
123
|
+
|
|
124
|
+
| Check | Rule |
|
|
125
|
+
|---|---|
|
|
126
|
+
| Signature | RS256 only. The algorithm is pinned, never read from the token header, which closes `alg: none` and the RSA-key-as-HMAC-secret confusion. It is checked before any key lookup, so such a token is refused as invalid even while the issuer is down |
|
|
127
|
+
| Key | the `kid` header must name an RS256 signing key the issuer publishes. If the issuer cannot be reached to look one up, the answer is 503, not 401 |
|
|
128
|
+
| Issuer | `iss` must equal the discovery document's `issuer` exactly, and that document must name the issuer you configured |
|
|
129
|
+
| Audience | `aud` must contain the audience you configured |
|
|
130
|
+
| Lifetime | `exp` and `iat` required, `nbf` honoured if present, with 60 seconds of clock skew allowed (`leeway=`, up to 300). An `iat` further in the future than that is rejected — stricter than Spring, which checks only `exp` and `nbf` |
|
|
131
|
+
| Required claims | `exp`, `iat`, `iss`, `aud`, `sub`, `jti` |
|
|
132
|
+
| Transport | the issuer must be `https://host[:port][/path]`; plain `http://` is accepted only for `localhost`, `127.0.0.1` and `::1` |
|
|
133
|
+
|
|
134
|
+
Keys are fetched on first use — constructing an `AuthGate` does no I/O — and cached for five
|
|
135
|
+
minutes. A failed refresh keeps serving the cached keys rather than failing every request — until
|
|
136
|
+
a refresh succeeds, however long that takes, so a key the issuer withdraws during an outage is
|
|
137
|
+
trusted until the verifier can see that it is gone.
|
|
138
|
+
|
|
139
|
+
## The caller
|
|
140
|
+
|
|
141
|
+
| `Identity` field | Claim | |
|
|
142
|
+
|---|---|---|
|
|
143
|
+
| `account_id` | `sub` | AuthGate's account id, not the external provider's subject |
|
|
144
|
+
| `tenant_id` | `tenant` | |
|
|
145
|
+
| `email` | `email` | for display and audit |
|
|
146
|
+
| `superuser` | `superuser` | `True` only for a literal JSON `true` |
|
|
147
|
+
| `permissions` | `perms` | `{resource: frozenset(actions)}` |
|
|
148
|
+
| `claims` | all of them | the verified payload; not part of equality, so two tokens for one caller compare equal |
|
|
149
|
+
|
|
150
|
+
## Errors
|
|
151
|
+
|
|
152
|
+
| Raised | Meaning | FastAPI response |
|
|
153
|
+
|---|---|---|
|
|
154
|
+
| — | no `Authorization: Bearer` header at all | 401, `WWW-Authenticate: Bearer` |
|
|
155
|
+
| `InvalidTokenError` | the token is not acceptable here | 401, `WWW-Authenticate: Bearer error="invalid_token"` |
|
|
156
|
+
| `IssuerUnavailableError` | the issuer's keys cannot be fetched | 503, with a fixed message; the detail is logged |
|
|
157
|
+
| `ConfigurationError` | the verifier is set up wrong, or the issuer's discovery document contradicts it | raised at construction; 500 if discovered later |
|
|
158
|
+
| — | a missing permission in `require()` | 403, `WWW-Authenticate: Bearer error="insufficient_scope"` |
|
|
159
|
+
|
|
160
|
+
All three exceptions derive from `AuthGateError`.
|
|
161
|
+
|
|
162
|
+
The public API is what `authgate` exports — `AuthGate`, `Identity` and the errors above. The
|
|
163
|
+
submodules are implementation, and may change between releases.
|
|
164
|
+
|
|
165
|
+
## Revocation
|
|
166
|
+
|
|
167
|
+
AuthGate revokes an access token by adding its `jti` to a denylist on the server. A verifier
|
|
168
|
+
working from the published keys cannot see that list, so a token that was already issued stays
|
|
169
|
+
valid here until it expires. The server's short access-token lifetime — 15 minutes by default — is
|
|
170
|
+
what bounds that window, and revocation is enforced where a new token would be issued. Every
|
|
171
|
+
JWT-issuing provider behaves this way.
|
|
172
|
+
|
|
173
|
+
## Development
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
pip install -e ".[dev]" "ruff==0.16.9" # the ruff CI pins
|
|
177
|
+
pytest
|
|
178
|
+
ruff check . && ruff format --check .
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
The tests run against an in-process fake issuer: a real RSA key, a real JWKS served over real HTTP
|
|
182
|
+
on `127.0.0.1`, and tokens carrying exactly the claims the server issues. That is what makes each
|
|
183
|
+
attack — `alg: none`, HMAC confusion, a foreign key reusing a known `kid`, a tampered payload — a
|
|
184
|
+
short test with no external network.
|
|
185
|
+
|
|
186
|
+
## License
|
|
187
|
+
|
|
188
|
+
MIT — see [LICENSE](https://github.com/boskodjokic/authgate-python/blob/main/LICENSE).
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
authgate/__init__.py,sha256=iZ-msjDlouABngQ1mr7qw_r0Ulu01_o_YuqxiQ9A3Nc,582
|
|
2
|
+
authgate/errors.py,sha256=Evst77iYG9YGFQxYOkNr5rbxZws7yWTBUz0Dgve9tHM,795
|
|
3
|
+
authgate/fastapi.py,sha256=NDnFWSiMkBl7Z2ZShUrt3NzGzQgGE8Eh3KKvbRF7S70,3109
|
|
4
|
+
authgate/identity.py,sha256=a16PvbbEtCbtBcfvZeEdnKWklITHfvyQO18jkrtS3bc,2495
|
|
5
|
+
authgate/keys.py,sha256=8lt-bNTRjJE1a0mmActlP_yELxg7eqhYAah5i7BIilk,7186
|
|
6
|
+
authgate/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
authgate/verifier.py,sha256=2tx5CkLyRWCDlky5OeHxjt9WBZGf8uhE3_2U95xZOzc,7150
|
|
8
|
+
authgate_client-0.1.0.dist-info/METADATA,sha256=O9lOyL81IpdyyM8tGzW_gZ2GyeoGZIqTKZa3itihTYk,8939
|
|
9
|
+
authgate_client-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
10
|
+
authgate_client-0.1.0.dist-info/licenses/LICENSE,sha256=bIQgMfVLAHq7LdY5FIYUi1I5XlO77i01JnXhYq5esso,1069
|
|
11
|
+
authgate_client-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Bosko Djokic
|
|
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.
|