grantor 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,18 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .venv/
7
+ venv/
8
+ .pytest_cache/
9
+ .ruff_cache/
10
+ .mypy_cache/
11
+ .coverage
12
+ htmlcov/
13
+ .tox/
14
+ .env
15
+ .DS_Store
16
+ uv.lock.bak
17
+ .venv*/
18
+ node_modules/
grantor-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,46 @@
1
+ Metadata-Version: 2.5
2
+ Name: grantor
3
+ Version: 0.1.0
4
+ Summary: The protocol core of the Grantor OAuth 2.1 / OpenID Connect client — framework-free.
5
+ Project-URL: Homepage, https://grantor.id
6
+ Project-URL: Documentation, https://docs.grantor.id
7
+ Project-URL: Source, https://github.com/Grantor-id/grantor-python
8
+ Project-URL: Issues, https://github.com/Grantor-id/grantor-python/issues
9
+ Author: Allure Labs
10
+ License-Expression: MIT
11
+ Keywords: authentication,grantor,oauth2,oidc,openid-connect,pkce
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Security
19
+ Classifier: Topic :: System :: Systems Administration :: Authentication/Directory
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.10
22
+ Requires-Dist: httpx>=0.27
23
+ Requires-Dist: pyjwt[crypto]>=2.9
24
+ Description-Content-Type: text/markdown
25
+
26
+ # `grantor`
27
+
28
+ The protocol core of the Python client for [Grantor](https://grantor.id), an
29
+ OAuth 2.1 / OpenID Connect provider. Discovery, PKCE, code exchange, JWKS
30
+ verification, userinfo, RP-initiated logout and error normalization — sync
31
+ and async, on `httpx`.
32
+
33
+ **No framework, no Django.** If you are writing a Django application you
34
+ want [`grantor-django`](https://pypi.org/project/grantor-django/) instead;
35
+ it depends on this package. If you are writing an adapter for another
36
+ framework, depend on this one alone: every pure step is public API, not a
37
+ private helper, precisely so you can compose them.
38
+
39
+ ```sh
40
+ pip install grantor
41
+ ```
42
+
43
+ Documentation: [docs.grantor.id](https://docs.grantor.id).
44
+ Source and security policy: [Grantor-id/grantor-python](https://github.com/Grantor-id/grantor-python).
45
+
46
+ MIT. © Allure Labs.
@@ -0,0 +1,21 @@
1
+ # `grantor`
2
+
3
+ The protocol core of the Python client for [Grantor](https://grantor.id), an
4
+ OAuth 2.1 / OpenID Connect provider. Discovery, PKCE, code exchange, JWKS
5
+ verification, userinfo, RP-initiated logout and error normalization — sync
6
+ and async, on `httpx`.
7
+
8
+ **No framework, no Django.** If you are writing a Django application you
9
+ want [`grantor-django`](https://pypi.org/project/grantor-django/) instead;
10
+ it depends on this package. If you are writing an adapter for another
11
+ framework, depend on this one alone: every pure step is public API, not a
12
+ private helper, precisely so you can compose them.
13
+
14
+ ```sh
15
+ pip install grantor
16
+ ```
17
+
18
+ Documentation: [docs.grantor.id](https://docs.grantor.id).
19
+ Source and security policy: [Grantor-id/grantor-python](https://github.com/Grantor-id/grantor-python).
20
+
21
+ MIT. © Allure Labs.
@@ -0,0 +1,43 @@
1
+ [project]
2
+ name = "grantor"
3
+ version = "0.1.0"
4
+ description = "The protocol core of the Grantor OAuth 2.1 / OpenID Connect client — framework-free."
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = "MIT"
8
+ authors = [{ name = "Allure Labs" }]
9
+ keywords = ["oauth2", "oidc", "openid-connect", "pkce", "authentication", "grantor"]
10
+ classifiers = [
11
+ "Development Status :: 3 - Alpha",
12
+ "Intended Audience :: Developers",
13
+ "Programming Language :: Python :: 3.10",
14
+ "Programming Language :: Python :: 3.11",
15
+ "Programming Language :: Python :: 3.12",
16
+ "Programming Language :: Python :: 3.13",
17
+ "Topic :: Security",
18
+ "Topic :: System :: Systems Administration :: Authentication/Directory",
19
+ "Typing :: Typed",
20
+ ]
21
+ dependencies = [
22
+ # httpx rather than requests, deliberately: the core has a sync and an
23
+ # async path so that a future `grantor-fastapi` composes these functions
24
+ # instead of blocking the event loop or reimplementing them.
25
+ "httpx>=0.27",
26
+ # PyJWT with cryptography for signature verification. JWKS is fetched
27
+ # through httpx rather than PyJWKClient's own urllib transport, so the
28
+ # async path and the sync path share one HTTP story.
29
+ "pyjwt[crypto]>=2.9",
30
+ ]
31
+
32
+ [project.urls]
33
+ Homepage = "https://grantor.id"
34
+ Documentation = "https://docs.grantor.id"
35
+ Source = "https://github.com/Grantor-id/grantor-python"
36
+ Issues = "https://github.com/Grantor-id/grantor-python/issues"
37
+
38
+ [build-system]
39
+ requires = ["hatchling"]
40
+ build-backend = "hatchling.build"
41
+
42
+ [tool.hatch.build.targets.wheel]
43
+ packages = ["src/grantor"]
@@ -0,0 +1,172 @@
1
+ """The protocol core of the Grantor client — no framework, no Django.
2
+
3
+ **Every pure step of the flow is public API here, not a private helper.**
4
+ ``build_authorization_url``, ``verify_id_token``, ``parse_error``,
5
+ ``exchange_code_request`` and the rest are importable and documented, and
6
+ :class:`GrantorClient` is a thin shell over them. That is the condition that
7
+ makes the split between this package and ``grantor-django`` worth having:
8
+ an adapter for a framework this package has never heard of composes these
9
+ instead of copying them. A core whose useful parts are all private is a core
10
+ that gets copy-pasted.
11
+
12
+ The rule the whole library is held to, and the reason it can exist beside a
13
+ promise that there is no proprietary SDK:
14
+
15
+ The library may never require anything of the issuer that a stock OIDC
16
+ library could not do. If a feature needs a change on the issuer side to
17
+ work, that change must be an RFC, or the feature is wrong.
18
+
19
+ One string is configured — the issuer. Every endpoint is read from its
20
+ discovery document.
21
+
22
+ >>> from grantor import GrantorClient
23
+ >>> client = GrantorClient(
24
+ ... "https://acme.api.grantor.id",
25
+ ... client_id="...",
26
+ ... client_secret="...",
27
+ ... redirect_uri="https://app.example.com/auth/callback",
28
+ ... )
29
+ >>> request = client.start_authorization() # doctest: +SKIP
30
+ >>> # send the browser to request.url; keep state, nonce and code_verifier
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ from ._discovery import (
36
+ DISCOVERY_TTL_SECONDS,
37
+ HTTP_TIMEOUT_SECONDS,
38
+ DiscoveryDocument,
39
+ async_discover,
40
+ clear_discovery_cache,
41
+ discover,
42
+ discovery_url,
43
+ parse_discovery_document,
44
+ )
45
+ from ._errors import (
46
+ DiscoveryError,
47
+ GrantorError,
48
+ ProtocolError,
49
+ TokenError,
50
+ normalize_error,
51
+ parse_error,
52
+ parse_redirect_error,
53
+ )
54
+ from ._pkce import (
55
+ CODE_CHALLENGE_METHOD,
56
+ PkcePair,
57
+ challenge_for,
58
+ generate_nonce,
59
+ generate_pkce,
60
+ generate_state,
61
+ generate_verifier,
62
+ )
63
+ from ._requests import (
64
+ ClientAuth,
65
+ TokenRequest,
66
+ append_query,
67
+ build_authorization_url,
68
+ build_end_session_url,
69
+ client_secret_basic,
70
+ client_secret_post,
71
+ exchange_code_request,
72
+ public_client,
73
+ refresh_token_request,
74
+ revocation_request,
75
+ userinfo_request,
76
+ )
77
+ from ._tokens import (
78
+ ACCESS_TOKEN_REQUIRED_CLAIMS,
79
+ ID_TOKEN_REQUIRED_CLAIMS,
80
+ LEEWAY_SECONDS,
81
+ JwksCache,
82
+ async_fetch_jwks,
83
+ async_verify_access_token,
84
+ async_verify_id_token,
85
+ check_nonce,
86
+ clear_jwks_cache,
87
+ decode_and_verify,
88
+ fetch_jwks,
89
+ verify_access_token,
90
+ verify_id_token,
91
+ )
92
+ from .client import (
93
+ DEFAULT_SCOPE,
94
+ AsyncGrantorClient,
95
+ AuthorizationRequest,
96
+ GrantorClient,
97
+ TokenResponse,
98
+ )
99
+
100
+ __version__ = "0.1.0"
101
+
102
+ __all__ = [
103
+ "__version__",
104
+ # Clients — thin shells over everything below them.
105
+ "GrantorClient",
106
+ "AsyncGrantorClient",
107
+ "AuthorizationRequest",
108
+ "TokenResponse",
109
+ "DEFAULT_SCOPE",
110
+ # Discovery.
111
+ "DiscoveryDocument",
112
+ "discover",
113
+ "async_discover",
114
+ "discovery_url",
115
+ "parse_discovery_document",
116
+ "clear_discovery_cache",
117
+ "DISCOVERY_TTL_SECONDS",
118
+ "HTTP_TIMEOUT_SECONDS",
119
+ # PKCE and the round-trip secrets.
120
+ "PkcePair",
121
+ "generate_pkce",
122
+ "generate_verifier",
123
+ "challenge_for",
124
+ "generate_state",
125
+ "generate_nonce",
126
+ "CODE_CHALLENGE_METHOD",
127
+ # Request shaping — pure, and public for exactly that reason.
128
+ "build_authorization_url",
129
+ "exchange_code_request",
130
+ "refresh_token_request",
131
+ "userinfo_request",
132
+ "revocation_request",
133
+ "build_end_session_url",
134
+ "append_query",
135
+ "ClientAuth",
136
+ "TokenRequest",
137
+ "client_secret_basic",
138
+ "client_secret_post",
139
+ "public_client",
140
+ # Verification.
141
+ "verify_id_token",
142
+ "async_verify_id_token",
143
+ "verify_access_token",
144
+ "async_verify_access_token",
145
+ "decode_and_verify",
146
+ "check_nonce",
147
+ "JwksCache",
148
+ "fetch_jwks",
149
+ "async_fetch_jwks",
150
+ "clear_jwks_cache",
151
+ "LEEWAY_SECONDS",
152
+ "ID_TOKEN_REQUIRED_CLAIMS",
153
+ "ACCESS_TOKEN_REQUIRED_CLAIMS",
154
+ # Errors, and the two envelopes they arrive in.
155
+ "GrantorError",
156
+ "DiscoveryError",
157
+ "ProtocolError",
158
+ "TokenError",
159
+ "parse_error",
160
+ "normalize_error",
161
+ "parse_redirect_error",
162
+ ]
163
+
164
+
165
+ def clear_caches() -> None:
166
+ """Drop the discovery and JWKS caches.
167
+
168
+ Both are process state with a TTL, which is right in production and
169
+ wrong in a test that has just changed what the issuer says.
170
+ """
171
+ clear_discovery_cache()
172
+ clear_jwks_cache()
@@ -0,0 +1,276 @@
1
+ """The issuer's discovery document, and the one check that matters about it.
2
+
3
+ One string is configured — the issuer. Every endpoint is read from here.
4
+ That is not tidiness: transcribing endpoints into configuration is how a
5
+ configuration goes stale while still looking correct, and the guide this
6
+ library follows records a week in which its own endpoint table named a
7
+ domain the product had already left.
8
+
9
+ The I/O and the parsing are separate on purpose. ``parse_discovery_document``
10
+ is pure, so the issuer-mismatch refusal — the security-relevant half — is
11
+ tested without a socket anywhere near it.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import threading
17
+ import time
18
+ from collections.abc import Mapping
19
+ from dataclasses import dataclass
20
+ from typing import Any
21
+
22
+ import httpx
23
+
24
+ from ._errors import DiscoveryError
25
+
26
+ __all__ = [
27
+ "DiscoveryDocument",
28
+ "discovery_url",
29
+ "parse_discovery_document",
30
+ "discover",
31
+ "async_discover",
32
+ "clear_discovery_cache",
33
+ "DISCOVERY_TTL_SECONDS",
34
+ "HTTP_TIMEOUT_SECONDS",
35
+ ]
36
+
37
+ DISCOVERY_PATH = "/.well-known/openid-configuration"
38
+ DISCOVERY_TTL_SECONDS = 3600.0
39
+ HTTP_TIMEOUT_SECONDS = 15.0
40
+
41
+ # Endpoints without which no flow can start. ``userinfo_endpoint`` and
42
+ # ``end_session_endpoint`` are deliberately not here: an issuer may omit
43
+ # them and the flows that need them fail on their own terms, with a message
44
+ # naming what was missing, rather than making discovery itself unusable.
45
+ _REQUIRED = ("authorization_endpoint", "token_endpoint", "jwks_uri")
46
+
47
+ # The algorithms a signature may be verified with. Intersected with whatever
48
+ # discovery advertises rather than trusted from it.
49
+ #
50
+ # All asymmetric. If a symmetric algorithm ever reached this list, a
51
+ # verifier handed a public key would use it as an HMAC secret — the
52
+ # algorithm-confusion attack, and the public key is published in the JWKS
53
+ # for anyone to fetch. Neither implementation this package was extracted
54
+ # from pinned this; both passed the advertised list straight through.
55
+ _SAFE_ALGORITHMS = frozenset(
56
+ {
57
+ "RS256",
58
+ "RS384",
59
+ "RS512",
60
+ "PS256",
61
+ "PS384",
62
+ "PS512",
63
+ "ES256",
64
+ "ES384",
65
+ "ES512",
66
+ "EdDSA",
67
+ }
68
+ )
69
+
70
+ _DEFAULT_ALGORITHMS = ("RS256",)
71
+
72
+
73
+ @dataclass(frozen=True)
74
+ class DiscoveryDocument:
75
+ """A validated ``openid-configuration``.
76
+
77
+ The raw document stays reachable through :meth:`get` — this issuer emits
78
+ ``claims_supported``, ``scopes_supported`` and more that a caller may
79
+ legitimately want, and a dataclass that hid them would send people back
80
+ to fetching the URL themselves.
81
+ """
82
+
83
+ issuer: str
84
+ claims: Mapping[str, Any]
85
+
86
+ def get(self, key: str, default: Any = None) -> Any:
87
+ return self.claims.get(key, default)
88
+
89
+ def require(self, key: str) -> str:
90
+ """An endpoint, or a refusal naming the one that is absent."""
91
+ value = self.claims.get(key)
92
+ if not isinstance(value, str) or not value:
93
+ raise DiscoveryError(f"discovery document lacks {key}")
94
+ return value
95
+
96
+ @property
97
+ def authorization_endpoint(self) -> str:
98
+ return self.require("authorization_endpoint")
99
+
100
+ @property
101
+ def token_endpoint(self) -> str:
102
+ return self.require("token_endpoint")
103
+
104
+ @property
105
+ def jwks_uri(self) -> str:
106
+ return self.require("jwks_uri")
107
+
108
+ @property
109
+ def userinfo_endpoint(self) -> str | None:
110
+ value = self.claims.get("userinfo_endpoint")
111
+ return value if isinstance(value, str) and value else None
112
+
113
+ @property
114
+ def end_session_endpoint(self) -> str | None:
115
+ value = self.claims.get("end_session_endpoint")
116
+ return value if isinstance(value, str) and value else None
117
+
118
+ @property
119
+ def revocation_endpoint(self) -> str | None:
120
+ value = self.claims.get("revocation_endpoint")
121
+ return value if isinstance(value, str) and value else None
122
+
123
+ @property
124
+ def signing_algorithms(self) -> tuple[str, ...]:
125
+ """The algorithms a token from this issuer may be verified with.
126
+
127
+ The advertised list, intersected with the asymmetric allowlist. An
128
+ issuer that advertises nothing usable leaves ``RS256``, which is
129
+ what every conforming OIDC provider signs with; an issuer that
130
+ advertises only unsafe algorithms gets the same answer, and the
131
+ signature check then fails honestly rather than succeeding against
132
+ a key used for the wrong purpose.
133
+ """
134
+ advertised = self.claims.get("id_token_signing_alg_values_supported")
135
+ if not isinstance(advertised, (list, tuple)):
136
+ return _DEFAULT_ALGORITHMS
137
+ safe = tuple(a for a in advertised if isinstance(a, str) and a in _SAFE_ALGORITHMS)
138
+ return safe or _DEFAULT_ALGORITHMS
139
+
140
+
141
+ def discovery_url(issuer: str) -> str:
142
+ """Where the document lives. Pure, and the only URL this library builds."""
143
+ return f"{issuer.rstrip('/')}{DISCOVERY_PATH}"
144
+
145
+
146
+ def parse_discovery_document(payload: Any, *, issuer: str) -> DiscoveryDocument:
147
+ """Validate a discovery payload against the issuer it was fetched for.
148
+
149
+ **The ``issuer`` claim must equal the configured issuer.** A document
150
+ naming a different one means the wrong server answered, or something
151
+ answered for it. Either way it is a refusal, not a warning — every
152
+ endpoint below is about to be taken from this document, so a document
153
+ that lies about who it belongs to redirects a sign-in wherever it likes.
154
+ """
155
+ if not isinstance(payload, Mapping):
156
+ raise DiscoveryError("discovery document is not a JSON object")
157
+
158
+ expected = issuer.rstrip("/")
159
+ named = payload.get("issuer")
160
+ if not isinstance(named, str) or named.rstrip("/") != expected:
161
+ raise DiscoveryError("discovery document names a different issuer")
162
+
163
+ for key in _REQUIRED:
164
+ value = payload.get(key)
165
+ if not isinstance(value, str) or not value:
166
+ raise DiscoveryError(f"discovery document lacks {key}")
167
+
168
+ return DiscoveryDocument(issuer=expected, claims=dict(payload))
169
+
170
+
171
+ class _Cache:
172
+ """Discovery documents by issuer, with a TTL.
173
+
174
+ Keyed by issuer rather than held as a single slot, because a process may
175
+ legitimately face two — a per-organization issuer for sign-in and the
176
+ platform one for something else — and a single slot would have them
177
+ evict each other on every call.
178
+ """
179
+
180
+ def __init__(self) -> None:
181
+ self._lock = threading.Lock()
182
+ self._entries: dict[str, tuple[DiscoveryDocument, float]] = {}
183
+
184
+ def get(self, issuer: str) -> DiscoveryDocument | None:
185
+ with self._lock:
186
+ entry = self._entries.get(issuer)
187
+ if entry is None:
188
+ return None
189
+ document, expires_at = entry
190
+ return document if time.monotonic() < expires_at else None
191
+
192
+ def put(self, issuer: str, document: DiscoveryDocument, ttl: float) -> None:
193
+ with self._lock:
194
+ self._entries[issuer] = (document, time.monotonic() + ttl)
195
+
196
+ def clear(self) -> None:
197
+ with self._lock:
198
+ self._entries.clear()
199
+
200
+
201
+ _cache = _Cache()
202
+
203
+
204
+ def clear_discovery_cache() -> None:
205
+ """Drop every cached document. The test hook, and the operational one."""
206
+ _cache.clear()
207
+
208
+
209
+ def _document_from_response(response: httpx.Response, *, issuer: str) -> DiscoveryDocument:
210
+ if response.status_code != 200:
211
+ raise DiscoveryError(f"discovery answered {response.status_code}")
212
+ try:
213
+ payload = response.json()
214
+ except ValueError as exc:
215
+ raise DiscoveryError("discovery did not return JSON") from exc
216
+ return parse_discovery_document(payload, issuer=issuer)
217
+
218
+
219
+ def discover(
220
+ issuer: str,
221
+ *,
222
+ client: httpx.Client | None = None,
223
+ ttl: float = DISCOVERY_TTL_SECONDS,
224
+ timeout: float = HTTP_TIMEOUT_SECONDS,
225
+ force: bool = False,
226
+ ) -> DiscoveryDocument:
227
+ """Fetch (or reuse) the issuer's discovery document."""
228
+ key = issuer.rstrip("/")
229
+ if not force:
230
+ cached = _cache.get(key)
231
+ if cached is not None:
232
+ return cached
233
+
234
+ url = discovery_url(key)
235
+ try:
236
+ if client is not None:
237
+ response = client.get(url, timeout=timeout)
238
+ else:
239
+ with httpx.Client(timeout=timeout, follow_redirects=True) as owned:
240
+ response = owned.get(url)
241
+ except httpx.HTTPError as exc:
242
+ raise DiscoveryError(f"discovery request failed: {type(exc).__name__}") from exc
243
+
244
+ document = _document_from_response(response, issuer=key)
245
+ _cache.put(key, document, ttl)
246
+ return document
247
+
248
+
249
+ async def async_discover(
250
+ issuer: str,
251
+ *,
252
+ client: httpx.AsyncClient | None = None,
253
+ ttl: float = DISCOVERY_TTL_SECONDS,
254
+ timeout: float = HTTP_TIMEOUT_SECONDS,
255
+ force: bool = False,
256
+ ) -> DiscoveryDocument:
257
+ """:func:`discover`, without blocking the event loop."""
258
+ key = issuer.rstrip("/")
259
+ if not force:
260
+ cached = _cache.get(key)
261
+ if cached is not None:
262
+ return cached
263
+
264
+ url = discovery_url(key)
265
+ try:
266
+ if client is not None:
267
+ response = await client.get(url, timeout=timeout)
268
+ else:
269
+ async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as owned:
270
+ response = await owned.get(url)
271
+ except httpx.HTTPError as exc:
272
+ raise DiscoveryError(f"discovery request failed: {type(exc).__name__}") from exc
273
+
274
+ document = _document_from_response(response, issuer=key)
275
+ _cache.put(key, document, ttl)
276
+ return document