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.
- grantor-0.1.0/.gitignore +18 -0
- grantor-0.1.0/PKG-INFO +46 -0
- grantor-0.1.0/README.md +21 -0
- grantor-0.1.0/pyproject.toml +43 -0
- grantor-0.1.0/src/grantor/__init__.py +172 -0
- grantor-0.1.0/src/grantor/_discovery.py +276 -0
- grantor-0.1.0/src/grantor/_errors.py +142 -0
- grantor-0.1.0/src/grantor/_pkce.py +76 -0
- grantor-0.1.0/src/grantor/_requests.py +281 -0
- grantor-0.1.0/src/grantor/_tokens.py +440 -0
- grantor-0.1.0/src/grantor/client.py +503 -0
- grantor-0.1.0/src/grantor/py.typed +0 -0
- grantor-0.1.0/tests/conftest.py +122 -0
- grantor-0.1.0/tests/core_support.py +27 -0
- grantor-0.1.0/tests/test_client.py +220 -0
- grantor-0.1.0/tests/test_discovery.py +159 -0
- grantor-0.1.0/tests/test_errors.py +89 -0
- grantor-0.1.0/tests/test_key_rotation.py +173 -0
- grantor-0.1.0/tests/test_live.py +67 -0
- grantor-0.1.0/tests/test_pkce_and_requests.py +216 -0
- grantor-0.1.0/tests/test_tokens.py +269 -0
grantor-0.1.0/.gitignore
ADDED
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.
|
grantor-0.1.0/README.md
ADDED
|
@@ -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
|