authgate-client 0.1.0__tar.gz

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.
@@ -0,0 +1,14 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ venv/
5
+ build/
6
+ dist/
7
+ *.egg-info/
8
+ .pytest_cache/
9
+ .ruff_cache/
10
+ .coverage
11
+ htmlcov/
12
+ .DS_Store
13
+ .idea/
14
+ .vscode/
@@ -0,0 +1,19 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 — 2026-09-27
4
+
5
+ First release.
6
+
7
+ **Added**
8
+
9
+ - `AuthGate(issuer, audience).verify(token)` — verifies an AuthGate access token and returns the
10
+ caller as a typed `Identity`. RS256 pinned; `iss`, `aud`, `exp` and `iat` checked; `sub` and
11
+ `jti` required; `nbf` honoured; `leeway=` clock skew, 60 s by default and at most 300. Keys come
12
+ from the issuer's discovery document and JWKS, are cached for five minutes, and are refetched on
13
+ an unknown `kid` at most once every 30 seconds.
14
+ - `Identity` — `account_id`, `tenant_id`, `email`, `superuser`, `permissions`, the verified
15
+ `claims`, and `may(resource, action)` with the server's superuser rule.
16
+ - FastAPI integration behind the `fastapi` extra: `Depends(auth.identity)` and
17
+ `Depends(auth.require(resource, action))`, with 401 / 403 / 503 responses.
18
+ - `InvalidTokenError`, `IssuerUnavailableError` and `ConfigurationError`, all derived from
19
+ `AuthGateError`.
@@ -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.
@@ -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,156 @@
1
+ # authgate-client
2
+
3
+ [![CI](https://github.com/boskodjokic/authgate-python/actions/workflows/ci.yml/badge.svg)](https://github.com/boskodjokic/authgate-python/actions/workflows/ci.yml)
4
+ [![PyPI](https://img.shields.io/pypi/v/authgate-client)](https://pypi.org/project/authgate-client/)
5
+ [![Python](https://img.shields.io/pypi/pyversions/authgate-client)](https://pypi.org/project/authgate-client/)
6
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green)](https://github.com/boskodjokic/authgate-python/blob/main/LICENSE)
7
+
8
+ Verify [AuthGate](https://github.com/boskodjokic/authgate) access tokens in Python. Framework-free,
9
+ with one dependency — PyJWT and its `crypto` extra — and an optional FastAPI integration. Installed
10
+ as `authgate-client`, imported as `authgate`.
11
+
12
+ ```python
13
+ from typing import Annotated
14
+
15
+ from fastapi import Depends, FastAPI
16
+
17
+ from authgate import AuthGate, Identity
18
+
19
+ auth = AuthGate(issuer="https://auth.example.com", audience="https://api.example.com")
20
+ app = FastAPI()
21
+
22
+
23
+ @app.get("/materials")
24
+ def list_materials(caller: Annotated[Identity, Depends(auth.identity)]):
25
+ return materials.for_tenant(caller.tenant_id)
26
+
27
+
28
+ @app.delete("/materials/{material_id}")
29
+ def delete_material(material_id: str, caller: Annotated[Identity, Depends(auth.require("material", "delete"))]):
30
+ materials.delete(material_id, by=caller.account_id)
31
+ ```
32
+
33
+ ```bash
34
+ pip install authgate-client # the verifier
35
+ pip install "authgate-client[fastapi]" # plus the FastAPI dependencies
36
+ ```
37
+
38
+ ## You may not need this
39
+
40
+ AuthGate publishes a standard `/.well-known/openid-configuration` and JWKS, so any JWT library that
41
+ can fetch a JWKS already verifies its tokens. This package is a convenience, the Python counterpart
42
+ of the server's Spring starter. What it adds is what a hand-written verifier usually gets wrong:
43
+
44
+ - **The audience is required.** A verifier that skips `aud` accepts every token the issuer ever
45
+ minted, including tokens meant for a different service. There is no default for "any".
46
+ - **A typed caller.** The `perms` claim arrives as `Identity.permissions`, and
47
+ `caller.may("material", "update")` applies the server's own rule, superuser included.
48
+ - **Key refresh that cannot be turned against the issuer.** Tokens naming an unknown `kid` trigger
49
+ at most one JWKS fetch every 30 seconds, however many arrive.
50
+ - **An outage is not a bad token.** If the issuer cannot be reached, the answer is 503, not 401 —
51
+ a 401 tells well-behaved clients to throw their token away and sign the user out.
52
+
53
+ ## Without FastAPI
54
+
55
+ `verify` is plain synchronous code, so it works in Flask, Django, a worker or a script:
56
+
57
+ ```python
58
+ from authgate import AuthGate, InvalidTokenError, IssuerUnavailableError
59
+
60
+ auth = AuthGate(issuer="https://auth.example.com", audience="https://api.example.com")
61
+
62
+ try:
63
+ caller = auth.verify(token)
64
+ except InvalidTokenError:
65
+ ... # 401
66
+ except IssuerUnavailableError:
67
+ ... # 503
68
+
69
+ if not caller.may("material", "update"):
70
+ ... # 403
71
+ ```
72
+
73
+ It is also why the FastAPI dependencies are plain `def`s: FastAPI runs them in its threadpool, so
74
+ the occasional key fetch never blocks the event loop. With the keys cached, verifying a token is
75
+ normally a signature check and nothing else.
76
+
77
+ ## Testing an app that uses it
78
+
79
+ Override `auth.identity` to stand in a caller. Every `require()` builds on it, so the permission
80
+ checks still run against the identity you supply:
81
+
82
+ ```python
83
+ app.dependency_overrides[auth.identity] = lambda: Identity("acct-1", permissions={"material": frozenset({"read"})})
84
+ ```
85
+
86
+ The dependencies are for HTTP routes, since they read the `Authorization` header. Use them with
87
+ `Depends`, not `Security(..., scopes=...)`: scopes make FastAPI treat each use as a separate
88
+ dependency, and the token would be verified once per use instead of once per request.
89
+
90
+ ## What is checked
91
+
92
+ | Check | Rule |
93
+ |---|---|
94
+ | 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 |
95
+ | 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 |
96
+ | Issuer | `iss` must equal the discovery document's `issuer` exactly, and that document must name the issuer you configured |
97
+ | Audience | `aud` must contain the audience you configured |
98
+ | 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` |
99
+ | Required claims | `exp`, `iat`, `iss`, `aud`, `sub`, `jti` |
100
+ | Transport | the issuer must be `https://host[:port][/path]`; plain `http://` is accepted only for `localhost`, `127.0.0.1` and `::1` |
101
+
102
+ Keys are fetched on first use — constructing an `AuthGate` does no I/O — and cached for five
103
+ minutes. A failed refresh keeps serving the cached keys rather than failing every request — until
104
+ a refresh succeeds, however long that takes, so a key the issuer withdraws during an outage is
105
+ trusted until the verifier can see that it is gone.
106
+
107
+ ## The caller
108
+
109
+ | `Identity` field | Claim | |
110
+ |---|---|---|
111
+ | `account_id` | `sub` | AuthGate's account id, not the external provider's subject |
112
+ | `tenant_id` | `tenant` | |
113
+ | `email` | `email` | for display and audit |
114
+ | `superuser` | `superuser` | `True` only for a literal JSON `true` |
115
+ | `permissions` | `perms` | `{resource: frozenset(actions)}` |
116
+ | `claims` | all of them | the verified payload; not part of equality, so two tokens for one caller compare equal |
117
+
118
+ ## Errors
119
+
120
+ | Raised | Meaning | FastAPI response |
121
+ |---|---|---|
122
+ | — | no `Authorization: Bearer` header at all | 401, `WWW-Authenticate: Bearer` |
123
+ | `InvalidTokenError` | the token is not acceptable here | 401, `WWW-Authenticate: Bearer error="invalid_token"` |
124
+ | `IssuerUnavailableError` | the issuer's keys cannot be fetched | 503, with a fixed message; the detail is logged |
125
+ | `ConfigurationError` | the verifier is set up wrong, or the issuer's discovery document contradicts it | raised at construction; 500 if discovered later |
126
+ | — | a missing permission in `require()` | 403, `WWW-Authenticate: Bearer error="insufficient_scope"` |
127
+
128
+ All three exceptions derive from `AuthGateError`.
129
+
130
+ The public API is what `authgate` exports — `AuthGate`, `Identity` and the errors above. The
131
+ submodules are implementation, and may change between releases.
132
+
133
+ ## Revocation
134
+
135
+ AuthGate revokes an access token by adding its `jti` to a denylist on the server. A verifier
136
+ working from the published keys cannot see that list, so a token that was already issued stays
137
+ valid here until it expires. The server's short access-token lifetime — 15 minutes by default — is
138
+ what bounds that window, and revocation is enforced where a new token would be issued. Every
139
+ JWT-issuing provider behaves this way.
140
+
141
+ ## Development
142
+
143
+ ```bash
144
+ pip install -e ".[dev]" "ruff==0.16.9" # the ruff CI pins
145
+ pytest
146
+ ruff check . && ruff format --check .
147
+ ```
148
+
149
+ The tests run against an in-process fake issuer: a real RSA key, a real JWKS served over real HTTP
150
+ on `127.0.0.1`, and tokens carrying exactly the claims the server issues. That is what makes each
151
+ attack — `alg: none`, HMAC confusion, a foreign key reusing a known `kid`, a tampered payload — a
152
+ short test with no external network.
153
+
154
+ ## License
155
+
156
+ MIT — see [LICENSE](https://github.com/boskodjokic/authgate-python/blob/main/LICENSE).
@@ -0,0 +1,61 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "authgate-client"
7
+ dynamic = ["version"]
8
+ description = "Verify AuthGate access tokens in Python, with an optional FastAPI integration."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "Bosko Djokic" }]
14
+ keywords = ["authgate", "jwt", "oidc", "jwks", "authentication", "fastapi"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Intended Audience :: Developers",
18
+ "Programming Language :: Python :: 3.10",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Programming Language :: Python :: 3.13",
22
+ "Programming Language :: Python :: 3.14",
23
+ "Topic :: Security",
24
+ "Typing :: Typed",
25
+ ]
26
+ # Every release before 2.13 carries a published advisory (CVE-2026-32597, CVE-2026-48522..48526 among
27
+ # them), and 2.10.0 compared `iss` with a substring match (CVE-2024-53861).
28
+ dependencies = ["pyjwt[crypto]>=2.13"]
29
+
30
+ [project.optional-dependencies]
31
+ fastapi = ["fastapi>=0.100"]
32
+ dev = ["pytest>=8", "ruff>=0.6", "fastapi>=0.100", "httpx2>=2.0"]
33
+
34
+ [project.urls]
35
+ Homepage = "https://github.com/boskodjokic/authgate-python"
36
+ Issues = "https://github.com/boskodjokic/authgate-python/issues"
37
+ Changelog = "https://github.com/boskodjokic/authgate-python/blob/main/CHANGELOG.md"
38
+ Server = "https://github.com/boskodjokic/authgate"
39
+
40
+ [tool.hatch.version]
41
+ path = "src/authgate/__init__.py"
42
+
43
+ [tool.hatch.build.targets.wheel]
44
+ packages = ["src/authgate"]
45
+
46
+ # An explicit list: hatch honours .gitignore but not .git/info/exclude, where the local-only
47
+ # planning docs are kept out of git, so without it they would ship in the sdist. Anchored with a
48
+ # leading slash — an unanchored pattern matches at any depth, so "tests" or "README.md" nested
49
+ # under docs/ would still be swept in.
50
+ [tool.hatch.build.targets.sdist]
51
+ include = ["/src", "/tests", "/CHANGELOG.md", "/LICENSE", "/README.md", "/pyproject.toml"]
52
+
53
+ [tool.pytest.ini_options]
54
+ testpaths = ["tests"]
55
+
56
+ [tool.ruff]
57
+ line-length = 120
58
+ target-version = "py310"
59
+
60
+ [tool.ruff.lint]
61
+ select = ["E", "F", "I", "UP", "B"]
@@ -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
+ ]
@@ -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
+ """
@@ -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
@@ -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())