Coverage for tests/oidc_client.py: 97%
133 statements
« prev ^ index » next coverage.py v7.6.10, created at 2025-01-09 16:45 +0100
« prev ^ index » next coverage.py v7.6.10, created at 2025-01-09 16:45 +0100
1import dataclasses
2import secrets
3from collections.abc import Iterable, Sequence
4from typing import Literal, Self, cast
5from urllib.parse import parse_qs, urlencode, urlsplit
7import authlib
8import pydantic
9import requests
12@dataclasses.dataclass(kw_only=True, frozen=True)
13class ProviderConfiguration:
14 """
15 https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata
16 """
18 @staticmethod
19 def fetch(issuer_url: str) -> "ProviderConfiguration":
20 # TODO: vaidate issuer url
21 response = requests.get(f"{issuer_url}/.well-known/openid-configuration")
22 response.raise_for_status()
23 return ProviderConfiguration.decode(response.json())
25 @staticmethod
26 def decode(data: object) -> "ProviderConfiguration":
27 # cache type adapter
28 return pydantic.TypeAdapter(ProviderConfiguration).validate_python(data)
30 issuer: str
31 # TODO: validate URL (scheme, no query, no fragment)
32 """
33 REQUIRED. URL using the https scheme with no query or fragment components
34 that the OP asserts as its Issuer Identifier.
35 """
37 authorization_endpoint: str
38 """
39 REQUIRED. URL of the OP's OAuth 2.0 Authorization Endpoint.
40 """
42 response_types_supported: Sequence[str]
43 """
44 REQUIRED. JSON array containing a list of the OAuth 2.0 response_type values
45 that this OP supports. Dynamic OpenID Providers MUST support 'code', 'id_token',
46 and 'id_token token'.
47 """
49 jwks_uri: str
50 """
51 REQUIRED. URL of the OP's JWK Set document.
52 """
54 subject_types_supported: list[Literal["pairwise", "public"]]
55 """
56 REQUIRED. JSON array containing a list of the Subject Identifier types that this OP supports.
57 Valid types include 'pairwise' and 'public'.
58 """
60 id_token_signing_alg_values_supported: list[str]
61 """
62 REQUIRED. JSON array containing a list of the JWS signing algorithms supported by the OP for the ID Token.
63 The algorithm 'RS256' MUST be included.
64 """
66 token_endpoint: str | None = None
67 """
68 URL of the OP's OAuth 2.0 Token Endpoint.
69 This is REQUIRED unless only the Implicit Flow is used.
70 """
72 userinfo_endpoint: str | None = None
73 """
74 RECOMMENDED. URL of the OP's UserInfo Endpoint.
75 """
77 registration_endpoint: str | None = None
78 """
79 RECOMMENDED. URL of the OP's Dynamic Client Registration Endpoint.
80 """
82 scopes_supported: list[str] | None = None
83 """
84 RECOMMENDED. JSON array containing a list of the OAuth 2.0 scope values
85 that this server supports, including the 'openid' scope.
86 """
88 response_modes_supported: list[str] | None = None
89 """
90 OPTIONAL. JSON array containing a list of the OAuth 2.0 response_mode values that this OP supports.
91 Default for Dynamic OpenID Providers is ["query", "fragment"].
92 """
94 grant_types_supported: list[Literal["authorization_code", "implicit"]] | None = None
95 """
96 OPTIONAL. JSON array containing a list of the OAuth 2.0 Grant Type values that this OP supports.
97 Dynamic OpenID Providers MUST support 'authorization_code' and 'implicit' Grant Types.
98 """
100 acr_values_supported: list[str] | None = None
101 """
102 OPTIONAL. JSON array containing a list of the Authentication Context Class References supported by this OP.
103 """
105 id_token_encryption_alg_values_supported: list[str] | None = None
106 """
107 OPTIONAL. JSON array containing a list of the JWE encryption algorithms supported by the OP for the ID Token.
108 """
110 id_token_encryption_enc_values_supported: list[str] | None = None
111 """
112 OPTIONAL. JSON array containing a list of the JWE encryption algorithms supported by the OP for the ID Token.
113 """
115 userinfo_signing_alg_values_supported: list[str] | None = None
116 """
117 OPTIONAL. JSON array containing a list of the JWS signing algorithms supported by the UserInfo Endpoint.
118 """
120 userinfo_encryption_alg_values_supported: list[str] | None = None
121 """
122 OPTIONAL. JSON array containing a list of the JWE encryption algorithms supported by the UserInfo Endpoint.
123 """
125 userinfo_encryption_enc_values_supported: list[str] | None = None
126 """
127 OPTIONAL. JSON array containing a list of the JWE encryption algorithms supported by the UserInfo Endpoint.
128 """
130 request_object_signing_alg_values_supported: list[str] | None = None
131 """
132 OPTIONAL. JSON array containing a list of the JWS signing algorithms supported by the OP for Request Objects.
133 """
135 request_object_encryption_alg_values_supported: list[str] | None = None
136 """
137 OPTIONAL. JSON array containing a list of the JWE encryption algorithms supported by the OP for Request Objects.
138 """
140 request_object_encryption_enc_values_supported: list[str] | None = None
141 """
142 OPTIONAL. JSON array containing a list of the JWE encryption algorithms supported by the OP for Request Objects.
143 """
145 token_endpoint_auth_methods_supported: (
146 list[
147 Literal[
148 "client_secret_post",
149 "client_secret_basic",
150 "client_secret_jwt",
151 "private_key_jwt",
152 ]
153 ]
154 | None
155 ) = None
156 """
157 OPTIONAL. JSON array containing a list of Client Authentication methods supported by this Token Endpoint.
158 """
160 token_endpoint_auth_signing_alg_values_supported: list[str] | None = None
161 """
162 OPTIONAL. JSON array containing a list of the JWS signing algorithms supported by the Token Endpoint for the signature.
163 """
165 display_values_supported: list[str] | None = None
166 """
167 OPTIONAL. JSON array containing a list of the display parameter values supported by the OpenID Provider.
168 """
170 claim_types_supported: list[str] | None = None
171 """
172 OPTIONAL. JSON array containing a list of the Claim Types that the OpenID Provider supports.
173 """
175 claims_supported: list[str] | None = None
176 """
177 RECOMMENDED. JSON array containing a list of the Claim Names that the OpenID Provider MAY supply values for.
178 """
180 service_documentation: str | None = None
181 """
182 OPTIONAL. URL of a page containing human-readable information that developers might need when using the OpenID Provider.
183 """
185 claims_locales_supported: list[str] | None = None
186 """
187 OPTIONAL. Languages and scripts supported for values in Claims being returned, represented as a JSON array of language tag values.
188 """
190 ui_locales_supported: list[str] | None = None
191 """
192 OPTIONAL. Languages and scripts supported for the user interface, represented as a JSON array of language tag values.
193 """
195 claims_parameter_supported: bool | None = None
196 """
197 OPTIONAL. Boolean value specifying whether the OP supports the claims parameter.
198 """
200 request_parameter_supported: bool | None = None
201 """
202 OPTIONAL. Boolean value specifying whether the OP supports the request parameter.
203 """
205 request_uri_parameter_supported: bool | None = None
206 """
207 OPTIONAL. Boolean value specifying whether the OP supports the request_uri parameter.
208 """
210 require_request_uri_registration: bool | None = None
211 """
212 OPTIONAL. Boolean value specifying whether the OP requires any request_uri values used to be pre-registered.
213 """
215 op_policy_uri: str | None = None
216 """
217 OPTIONAL. URL that the OpenID Provider provides to read about the OP's requirements for the Relying Party.
218 """
220 op_tos_uri: str | None = None
221 """
222 OPTIONAL. URL that the OpenID Provider provides to read about its terms of service.
223 """
225 def as_dict(self) -> dict[str, object]:
226 return dataclasses.asdict(self)
229@dataclasses.dataclass(kw_only=True, frozen=True)
230class AuthorizationRequest:
231 url: str
232 state: str
233 nonce: str | None
234 client_id: str
237def start_authorization(
238 provider_config: ProviderConfiguration,
239 *,
240 client_id: str,
241 # client_secret: str,
242 redirect_uri: str,
243 nonce: bool = True,
244 scope: Iterable[str] = ("openid",),
245) -> AuthorizationRequest:
246 """
247 https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest
248 """
249 # TODO: check "openid in scoe"
250 state = secrets.token_urlsafe(16)
252 if nonce: 252 ↛ 255line 252 didn't jump to line 255 because the condition on line 252 was always true
253 nonce_value = secrets.token_urlsafe(16)
254 else:
255 nonce_value = None
257 query = {
258 "state": state,
259 "client_id": client_id,
260 "redirect_uri": redirect_uri,
261 "nonce": nonce_value,
262 }
264 url = urlsplit(provider_config.authorization_endpoint)
265 url = url._replace(query=urlencode(query)).geturl()
266 return AuthorizationRequest(
267 url=url,
268 state=state,
269 nonce=nonce_value,
270 client_id=client_id,
271 )
274@dataclasses.dataclass(kw_only=True, frozen=True)
275class AuthentiationResult:
276 access_token: str
277 claims: dict[str, str]
280@dataclasses.dataclass
281class TokenResponse:
282 access_token: str
283 id_token: str
285 @classmethod
286 def decode(cls, data: object) -> Self:
287 # cache type adapter
288 return pydantic.TypeAdapter(cls).validate_python(data)
291def authenticate(
292 openid_config: ProviderConfiguration,
293 request: AuthorizationRequest,
294 query_string: str,
295) -> AuthentiationResult:
296 # TODO: return access_token, refresh_token, etc
297 query = parse_qs(query_string)
299 # TODO: handle errors
301 # TODO: raise exception
302 assert query["state"] == [request.state]
304 # TODO: ValueError
305 code = query["code"][0]
307 # TODO: ValueError
308 assert openid_config.token_endpoint
310 response = requests.post(openid_config.token_endpoint, data={"code": code})
311 response.raise_for_status()
312 token_response = TokenResponse.decode(response.json())
314 keys = requests.get(openid_config.jwks_uri).json()
315 keys = authlib.jose.JsonWebKey.import_key_set(keys) # type: ignore
317 claims = authlib.jose.JsonWebToken(["RS256"]).decode(token_response.id_token, keys) # type: ignore
318 claims = cast("dict[str, str]", claims)
320 # TODO: check claims
321 assert claims["iss"] == openid_config.issuer
322 assert claims["aud"] == request.client_id
323 if request.nonce is not None or "nonce" in claims: 323 ↛ 326line 323 didn't jump to line 326 because the condition on line 323 was always true
324 assert claims["nonce"] == request.nonce
326 return AuthentiationResult(
327 access_token=token_response.access_token,
328 claims=claims,
329 )