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 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
+ [![CI](https://github.com/boskodjokic/authgate-python/actions/workflows/ci.yml/badge.svg)](https://github.com/boskodjokic/authgate-python/actions/workflows/ci.yml)
36
+ [![PyPI](https://img.shields.io/pypi/v/authgate-client)](https://pypi.org/project/authgate-client/)
37
+ [![Python](https://img.shields.io/pypi/pyversions/authgate-client)](https://pypi.org/project/authgate-client/)
38
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.