spakky-oidc 6.5.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,83 @@
1
+ Metadata-Version: 2.3
2
+ Name: spakky-oidc
3
+ Version: 6.5.0
4
+ Summary: OIDC bearer authentication provider plugin for Spakky framework
5
+ Author: Spakky
6
+ Author-email: Spakky <sejong418@icloud.com>
7
+ License: MIT
8
+ Requires-Dist: pyjwt[crypto]>=2.10.1
9
+ Requires-Dist: spakky>=6.5.0
10
+ Requires-Dist: spakky-auth>=6.5.0
11
+ Requires-Python: >=3.12
12
+ Description-Content-Type: text/markdown
13
+
14
+ # spakky-oidc
15
+
16
+ `spakky-oidc` authenticates OIDC/OAuth bearer JWT credentials and maps
17
+ validated claims into `spakky.auth.AuthContext`.
18
+
19
+ The provider contributes `AuthCapability.AUTHENTICATION` through the
20
+ `spakky.contributions.spakky.auth` entry point. It intentionally contains no
21
+ browser login, callback, session, refresh, or logout routes; inbound adapters
22
+ pass an already-observed bearer credential to the provider-neutral
23
+ `IAuthenticationProvider` port.
24
+
25
+ ## Capabilities
26
+
27
+ - OIDC discovery from `issuer/.well-known/openid-configuration` or an explicit
28
+ discovery URL.
29
+ - JWKS key selection by `kid` and RS256 signature verification.
30
+ - `issuer`, `audience`, `azp`, `exp`, `nbf`, `iat`, and clock skew validation.
31
+ - `sub`, display name, tenant, role, scope, and selected safe claim mapping to
32
+ `AuthContext`.
33
+ - Raw bearer token exclusion from `AuthContext.claims`, `metadata`, and
34
+ `credential_carrier`.
35
+
36
+ ## Installation
37
+
38
+ ```bash
39
+ pip install spakky-oidc
40
+ ```
41
+
42
+ ## Usage
43
+
44
+ ```python
45
+ from spakky.auth import (
46
+ AuthInvocation,
47
+ CredentialCarrier,
48
+ CredentialCarrierKind,
49
+ CredentialCarrierLocation,
50
+ )
51
+ from spakky.plugins.oidc import OidcAuthenticationProvider, OidcProviderConfig
52
+
53
+ provider = OidcAuthenticationProvider(
54
+ config=OidcProviderConfig(
55
+ issuer="https://issuer.example.test",
56
+ audience="api://spakky",
57
+ client_id="spakky-client",
58
+ )
59
+ )
60
+
61
+ auth_context = provider.authenticate(
62
+ CredentialCarrier(
63
+ kind=CredentialCarrierKind.BEARER_TOKEN,
64
+ location=CredentialCarrierLocation.AUTHORIZATION_HEADER,
65
+ material="eyJ...",
66
+ name="Authorization",
67
+ scheme="Bearer",
68
+ ),
69
+ AuthInvocation(boundary="http", operation="GET /documents"),
70
+ )
71
+ ```
72
+
73
+ `OidcProviderConfig` controls claim mapping via `roles_claim`, `scopes_claim`,
74
+ `tenant_claim`, `display_name_claim`, and `retained_claim_names`. The default
75
+ scope claim accepts the standard space-delimited `scope` string; role and custom
76
+ scope claims may also use string arrays.
77
+
78
+ `authenticate_result()` is available for boundary adapters that prefer a
79
+ provider-neutral `AuthorizationDecision` instead of exception handling.
80
+
81
+ ## License
82
+
83
+ MIT License
@@ -0,0 +1,70 @@
1
+ # spakky-oidc
2
+
3
+ `spakky-oidc` authenticates OIDC/OAuth bearer JWT credentials and maps
4
+ validated claims into `spakky.auth.AuthContext`.
5
+
6
+ The provider contributes `AuthCapability.AUTHENTICATION` through the
7
+ `spakky.contributions.spakky.auth` entry point. It intentionally contains no
8
+ browser login, callback, session, refresh, or logout routes; inbound adapters
9
+ pass an already-observed bearer credential to the provider-neutral
10
+ `IAuthenticationProvider` port.
11
+
12
+ ## Capabilities
13
+
14
+ - OIDC discovery from `issuer/.well-known/openid-configuration` or an explicit
15
+ discovery URL.
16
+ - JWKS key selection by `kid` and RS256 signature verification.
17
+ - `issuer`, `audience`, `azp`, `exp`, `nbf`, `iat`, and clock skew validation.
18
+ - `sub`, display name, tenant, role, scope, and selected safe claim mapping to
19
+ `AuthContext`.
20
+ - Raw bearer token exclusion from `AuthContext.claims`, `metadata`, and
21
+ `credential_carrier`.
22
+
23
+ ## Installation
24
+
25
+ ```bash
26
+ pip install spakky-oidc
27
+ ```
28
+
29
+ ## Usage
30
+
31
+ ```python
32
+ from spakky.auth import (
33
+ AuthInvocation,
34
+ CredentialCarrier,
35
+ CredentialCarrierKind,
36
+ CredentialCarrierLocation,
37
+ )
38
+ from spakky.plugins.oidc import OidcAuthenticationProvider, OidcProviderConfig
39
+
40
+ provider = OidcAuthenticationProvider(
41
+ config=OidcProviderConfig(
42
+ issuer="https://issuer.example.test",
43
+ audience="api://spakky",
44
+ client_id="spakky-client",
45
+ )
46
+ )
47
+
48
+ auth_context = provider.authenticate(
49
+ CredentialCarrier(
50
+ kind=CredentialCarrierKind.BEARER_TOKEN,
51
+ location=CredentialCarrierLocation.AUTHORIZATION_HEADER,
52
+ material="eyJ...",
53
+ name="Authorization",
54
+ scheme="Bearer",
55
+ ),
56
+ AuthInvocation(boundary="http", operation="GET /documents"),
57
+ )
58
+ ```
59
+
60
+ `OidcProviderConfig` controls claim mapping via `roles_claim`, `scopes_claim`,
61
+ `tenant_claim`, `display_name_claim`, and `retained_claim_names`. The default
62
+ scope claim accepts the standard space-delimited `scope` string; role and custom
63
+ scope claims may also use string arrays.
64
+
65
+ `authenticate_result()` is available for boundary adapters that prefer a
66
+ provider-neutral `AuthorizationDecision` instead of exception handling.
67
+
68
+ ## License
69
+
70
+ MIT License
@@ -0,0 +1,78 @@
1
+ [project]
2
+ name = "spakky-oidc"
3
+ version = "6.5.0"
4
+ description = "OIDC bearer authentication provider plugin for Spakky framework"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ license = { text = "MIT" }
8
+ authors = [{ name = "Spakky", email = "sejong418@icloud.com" }]
9
+ dependencies = [
10
+ "pyjwt[crypto]>=2.10.1",
11
+ "spakky>=6.5.0",
12
+ "spakky-auth>=6.5.0",
13
+ ]
14
+
15
+ [project.entry-points."spakky.plugins"]
16
+ spakky-oidc = "spakky.plugins.oidc.main:initialize"
17
+
18
+ [project.entry-points."spakky.contributions.spakky.auth"]
19
+ spakky-oidc = "spakky.plugins.oidc.contributions.auth:initialize"
20
+
21
+ [build-system]
22
+ requires = ["uv_build>=0.10.10,<0.11.0"]
23
+ build-backend = "uv_build"
24
+
25
+ [tool.uv.build-backend]
26
+ module-root = "src"
27
+ module-name = "spakky.plugins.oidc"
28
+
29
+ [tool.pyrefly]
30
+ python-version = "3.12"
31
+ search_path = ["src", ".", "../../core/spakky/src", "../../core/spakky-auth/src"]
32
+ project_excludes = ["**/__pycache__", "**/*.pyc"]
33
+
34
+ [tool.ruff]
35
+ builtins = ["_"]
36
+ cache-dir = "~/.cache/ruff"
37
+
38
+ [tool.pytest.ini_options]
39
+ pythonpath = ["src", "../../core/spakky/src", "../../core/spakky-auth/src"]
40
+ testpaths = "tests"
41
+ python_files = ["test_*.py"]
42
+ asyncio_mode = "auto"
43
+ addopts = """
44
+ --cov
45
+ --cov-report=term
46
+ --cov-report=xml
47
+ --no-cov-on-fail
48
+ --strict-markers
49
+ --dist=load
50
+ -p no:warnings
51
+ -n auto
52
+ --spec
53
+ """
54
+ spec_test_format = "{result} {docstring_summary}"
55
+
56
+ [tool.coverage.run]
57
+ include = ["src/spakky/plugins/oidc/**/*.py"]
58
+ branch = true
59
+
60
+ [tool.coverage.report]
61
+ show_missing = true
62
+ precision = 2
63
+ fail_under = 100
64
+ skip_empty = true
65
+ exclude_lines = [
66
+ "pragma: no cover",
67
+ "def __repr__",
68
+ "raise AssertionError",
69
+ "raise NotImplementedError",
70
+ "@(abc\\.)?abstractmethod",
71
+ "@(typing\\.)?overload",
72
+ "\\.\\.\\.",
73
+ "pass",
74
+ ]
75
+
76
+ [tool.uv.sources]
77
+ spakky = { workspace = true }
78
+ spakky-auth = { workspace = true }
@@ -0,0 +1,39 @@
1
+ """OIDC bearer authentication provider plugin public API."""
2
+
3
+ from spakky.core.application.plugin import Plugin
4
+ from spakky.plugins.oidc.error import (
5
+ AbstractSpakkyOidcError,
6
+ OidcCredentialError,
7
+ OidcDiscoveryError,
8
+ OidcJwksError,
9
+ OidcTokenValidationError,
10
+ )
11
+ from spakky.plugins.oidc.provider import (
12
+ OIDC_AUTH_PROVIDER_ID,
13
+ DEFAULT_RETAINED_CLAIMS,
14
+ OidcAuthenticationProvider,
15
+ OidcAuthenticationResult,
16
+ OidcDiscoveryMetadata,
17
+ OidcProviderConfig,
18
+ fetch_json_document,
19
+ oidc_auth_provider_contribution,
20
+ )
21
+
22
+ PLUGIN_NAME = Plugin(name="spakky-oidc")
23
+
24
+ __all__ = [
25
+ "PLUGIN_NAME",
26
+ "AbstractSpakkyOidcError",
27
+ "DEFAULT_RETAINED_CLAIMS",
28
+ "OIDC_AUTH_PROVIDER_ID",
29
+ "OidcAuthenticationProvider",
30
+ "OidcAuthenticationResult",
31
+ "OidcCredentialError",
32
+ "OidcDiscoveryError",
33
+ "OidcDiscoveryMetadata",
34
+ "OidcJwksError",
35
+ "OidcProviderConfig",
36
+ "OidcTokenValidationError",
37
+ "fetch_json_document",
38
+ "oidc_auth_provider_contribution",
39
+ ]
@@ -0,0 +1 @@
1
+ """OIDC feature contribution entry points."""
@@ -0,0 +1,9 @@
1
+ """Auth feature contribution for the OIDC provider."""
2
+
3
+ from spakky.core.application.application import SpakkyApplication
4
+ from spakky.plugins.oidc.provider import oidc_auth_provider_contribution
5
+
6
+
7
+ def initialize(app: SpakkyApplication) -> None:
8
+ """Register OIDC auth capability metadata."""
9
+ app.add(oidc_auth_provider_contribution)
@@ -0,0 +1,39 @@
1
+ """OIDC provider-specific errors."""
2
+
3
+ from typing import final
4
+
5
+ from spakky.core.common.error import AbstractSpakkyFrameworkError
6
+
7
+
8
+ class AbstractSpakkyOidcError(AbstractSpakkyFrameworkError):
9
+ """Base class for spakky-oidc provider errors."""
10
+
11
+ message = "OIDC bearer authentication failed"
12
+
13
+
14
+ @final
15
+ class OidcDiscoveryError(AbstractSpakkyOidcError):
16
+ """Raised when OIDC discovery metadata cannot be loaded or trusted."""
17
+
18
+ message = "OIDC discovery metadata is unavailable or invalid"
19
+
20
+
21
+ @final
22
+ class OidcJwksError(AbstractSpakkyOidcError):
23
+ """Raised when JWKS keys cannot validate the bearer credential."""
24
+
25
+ message = "OIDC JWKS key material is unavailable or invalid"
26
+
27
+
28
+ @final
29
+ class OidcCredentialError(AbstractSpakkyOidcError):
30
+ """Raised when the credential carrier is not a usable bearer token."""
31
+
32
+ message = "OIDC bearer credential is missing or invalid"
33
+
34
+
35
+ @final
36
+ class OidcTokenValidationError(AbstractSpakkyOidcError):
37
+ """Raised when JWT claims or signatures fail OIDC validation."""
38
+
39
+ message = "OIDC bearer token validation failed"
@@ -0,0 +1,9 @@
1
+ """Plugin initialization for OIDC bearer authentication."""
2
+
3
+ from spakky.core.application.application import SpakkyApplication
4
+ from spakky.plugins.oidc.provider import OidcAuthenticationProvider
5
+
6
+
7
+ def initialize(app: SpakkyApplication) -> None:
8
+ """Register the OIDC authentication provider."""
9
+ app.add(OidcAuthenticationProvider)
@@ -0,0 +1,420 @@
1
+ """OIDC bearer authentication provider."""
2
+
3
+ from collections.abc import Callable
4
+ from dataclasses import dataclass
5
+ from datetime import timedelta
6
+ from typing import final, override, cast
7
+ from urllib.error import URLError
8
+ from urllib.request import urlopen
9
+ import json
10
+
11
+ import jwt
12
+ from jwt import PyJWK, PyJWTError
13
+
14
+ from spakky.auth import (
15
+ AuthCapability,
16
+ AuthClaim,
17
+ AuthClaimValue,
18
+ AuthContext,
19
+ AuthInvocation,
20
+ AuthProviderContribution,
21
+ AuthSubject,
22
+ AuthorizationDecision,
23
+ AuthorizationReasonCode,
24
+ CredentialCarrier,
25
+ CredentialCarrierKind,
26
+ IAuthenticationProvider,
27
+ )
28
+ from spakky.core.pod.annotations.pod import Pod
29
+ from spakky.plugins.oidc.error import (
30
+ AbstractSpakkyOidcError,
31
+ OidcCredentialError,
32
+ OidcDiscoveryError,
33
+ OidcJwksError,
34
+ OidcTokenValidationError,
35
+ )
36
+
37
+ OIDC_AUTH_PROVIDER_ID = "provider:spakky-oidc"
38
+ """Stable auth provider id advertised by spakky-oidc."""
39
+
40
+ DEFAULT_RETAINED_CLAIMS = (
41
+ "sub",
42
+ "iss",
43
+ "aud",
44
+ "azp",
45
+ "email",
46
+ "name",
47
+ "preferred_username",
48
+ )
49
+ """Safe claim names retained in AuthContext; raw token material is excluded."""
50
+
51
+ JsonObject = dict[str, object]
52
+ JsonFetcher = Callable[[str], JsonObject]
53
+
54
+
55
+ def fetch_json_document(url: str) -> JsonObject:
56
+ """Fetch a JSON object from an OIDC metadata URL."""
57
+ try:
58
+ with urlopen(url, timeout=5) as response:
59
+ payload = json.load(response)
60
+ except (OSError, URLError, json.JSONDecodeError) as exc:
61
+ raise OidcDiscoveryError() from exc
62
+ if isinstance(payload, dict):
63
+ return _string_keyed_dict(payload)
64
+ raise OidcDiscoveryError()
65
+
66
+
67
+ @dataclass(frozen=True, slots=True, kw_only=True)
68
+ class OidcProviderConfig:
69
+ """Runtime config for OIDC bearer authentication."""
70
+
71
+ issuer: str
72
+ """Expected issuer and base URL for discovery when discovery_url is omitted."""
73
+
74
+ audience: str | tuple[str, ...]
75
+ """Accepted audience value or values."""
76
+
77
+ client_id: str | None = None
78
+ """Expected authorized party (`azp`) when the token carries it."""
79
+
80
+ discovery_url: str | None = None
81
+ """Explicit OIDC discovery URL; defaults to issuer/.well-known/openid-configuration."""
82
+
83
+ algorithm: str = "RS256"
84
+ """Expected JWT signing algorithm."""
85
+
86
+ clock_skew: timedelta = timedelta(seconds=60)
87
+ """Allowed exp/nbf/iat clock skew."""
88
+
89
+ retained_claim_names: tuple[str, ...] = DEFAULT_RETAINED_CLAIMS
90
+ """JWT claim names safe to retain on AuthContext."""
91
+
92
+ roles_claim: str = "roles"
93
+ """Claim containing role refs as a string or string array."""
94
+
95
+ scopes_claim: str = "scope"
96
+ """Claim containing scope refs as a space-delimited string or string array."""
97
+
98
+ tenant_claim: str | None = "tenant"
99
+ """Optional claim containing the tenant canonical ref."""
100
+
101
+ display_name_claim: str | None = "name"
102
+ """Optional claim containing a human-readable subject label."""
103
+
104
+ json_fetcher: JsonFetcher = fetch_json_document
105
+ """Fetches discovery and JWKS JSON; injectable for deterministic tests."""
106
+
107
+ provider_available: bool = True
108
+ """Whether provider dependencies are usable at runtime."""
109
+
110
+
111
+ @dataclass(frozen=True, slots=True, kw_only=True)
112
+ class OidcAuthenticationResult:
113
+ """Decision plus optional AuthContext produced by bearer authentication."""
114
+
115
+ decision: AuthorizationDecision
116
+ """ALLOW, CHALLENGE, or ERROR decision for the authentication attempt."""
117
+
118
+ auth_context: AuthContext | None = None
119
+ """Authenticated context when decision is ALLOW."""
120
+
121
+
122
+ @dataclass(frozen=True, slots=True, kw_only=True)
123
+ class OidcDiscoveryMetadata:
124
+ """Trusted subset of OIDC discovery metadata."""
125
+
126
+ issuer: str
127
+ """Issuer reported by the discovery document."""
128
+
129
+ jwks_uri: str
130
+ """JWKS endpoint used to select token verification keys."""
131
+
132
+
133
+ @Pod()
134
+ @final
135
+ class OidcAuthenticationProvider(IAuthenticationProvider):
136
+ """OIDC JWT bearer implementation of the provider-neutral auth port."""
137
+
138
+ _config: OidcProviderConfig
139
+
140
+ def __init__(
141
+ self,
142
+ config: OidcProviderConfig = OidcProviderConfig(
143
+ issuer="https://issuer.example.test",
144
+ audience="spakky",
145
+ ),
146
+ ) -> None:
147
+ self._config = config
148
+
149
+ @override
150
+ def authenticate(
151
+ self,
152
+ credential: CredentialCarrier,
153
+ invocation: AuthInvocation,
154
+ ) -> AuthContext:
155
+ """Authenticate an OIDC bearer credential and return AuthContext."""
156
+ if not self._config.provider_available:
157
+ raise OidcDiscoveryError()
158
+ token = self._bearer_token(credential)
159
+ metadata = self._discovery_metadata()
160
+ jwk = self._matching_jwk(token, metadata.jwks_uri)
161
+ claims = self._verified_claims(token, jwk, metadata.issuer)
162
+ return self._auth_context_from_claims(claims)
163
+
164
+ def authenticate_result(
165
+ self,
166
+ credential: CredentialCarrier,
167
+ invocation: AuthInvocation,
168
+ ) -> OidcAuthenticationResult:
169
+ """Authenticate a bearer token and map failures to auth decisions."""
170
+ try:
171
+ auth_context = self.authenticate(credential, invocation)
172
+ except OidcCredentialError:
173
+ return OidcAuthenticationResult(
174
+ decision=AuthorizationDecision.challenge(
175
+ AuthorizationReasonCode.MISSING_CREDENTIAL
176
+ )
177
+ )
178
+ except OidcTokenValidationError:
179
+ return OidcAuthenticationResult(
180
+ decision=AuthorizationDecision.challenge(
181
+ AuthorizationReasonCode.INVALID_CREDENTIAL
182
+ )
183
+ )
184
+ except OidcJwksError:
185
+ return OidcAuthenticationResult(
186
+ decision=AuthorizationDecision.challenge(
187
+ AuthorizationReasonCode.INVALID_CREDENTIAL
188
+ )
189
+ )
190
+ except OidcDiscoveryError:
191
+ return OidcAuthenticationResult(
192
+ decision=AuthorizationDecision.error(
193
+ AuthorizationReasonCode.VERIFICATION_PROVIDER_UNAVAILABLE
194
+ )
195
+ )
196
+ return OidcAuthenticationResult(
197
+ decision=AuthorizationDecision.allow(),
198
+ auth_context=auth_context,
199
+ )
200
+
201
+ def _bearer_token(self, credential: CredentialCarrier) -> str:
202
+ if credential.kind is not CredentialCarrierKind.BEARER_TOKEN:
203
+ raise OidcCredentialError()
204
+ if credential.material == "":
205
+ raise OidcCredentialError()
206
+ if credential.scheme is not None and credential.scheme.lower() != "bearer":
207
+ raise OidcCredentialError()
208
+ return credential.material
209
+
210
+ def _discovery_metadata(self) -> OidcDiscoveryMetadata:
211
+ document = self._config.json_fetcher(self._discovery_url())
212
+ issuer = _required_string(document, "issuer", OidcDiscoveryError)
213
+ jwks_uri = _required_string(document, "jwks_uri", OidcDiscoveryError)
214
+ if issuer != self._config.issuer:
215
+ raise OidcDiscoveryError()
216
+ return OidcDiscoveryMetadata(issuer=issuer, jwks_uri=jwks_uri)
217
+
218
+ def _discovery_url(self) -> str:
219
+ if self._config.discovery_url is not None:
220
+ return self._config.discovery_url
221
+ return f"{self._config.issuer.rstrip('/')}/.well-known/openid-configuration"
222
+
223
+ def _matching_jwk(self, token: str, jwks_uri: str) -> JsonObject:
224
+ header = self._token_header(token)
225
+ algorithm = _required_string(header, "alg", OidcTokenValidationError)
226
+ if algorithm != self._config.algorithm:
227
+ raise OidcTokenValidationError()
228
+ key_id = _required_string(header, "kid", OidcTokenValidationError)
229
+ jwks = self._config.json_fetcher(jwks_uri)
230
+ keys = jwks.get("keys")
231
+ if not isinstance(keys, list):
232
+ raise OidcJwksError()
233
+ for key in keys:
234
+ if not isinstance(key, dict):
235
+ raise OidcJwksError()
236
+ jwk = _string_keyed_dict(key)
237
+ if jwk.get("kid") == key_id:
238
+ return jwk
239
+ raise OidcJwksError()
240
+
241
+ def _token_header(self, token: str) -> JsonObject:
242
+ try:
243
+ return _string_keyed_dict(
244
+ cast(dict[object, object], jwt.get_unverified_header(token))
245
+ )
246
+ except PyJWTError as exc:
247
+ raise OidcTokenValidationError() from exc
248
+
249
+ def _verified_claims(
250
+ self,
251
+ token: str,
252
+ jwk: JsonObject,
253
+ issuer: str,
254
+ ) -> JsonObject:
255
+ try:
256
+ signing_key = PyJWK.from_dict(jwk).key
257
+ payload = jwt.decode(
258
+ token,
259
+ key=signing_key,
260
+ algorithms=(self._config.algorithm,),
261
+ audience=self._config.audience,
262
+ issuer=issuer,
263
+ leeway=self._config.clock_skew,
264
+ options={"require": ["sub", "iss", "aud", "exp", "iat"]},
265
+ )
266
+ except PyJWTError as exc:
267
+ raise OidcTokenValidationError() from exc
268
+ claims = _string_keyed_dict(cast(dict[object, object], payload))
269
+ self._validate_authorized_party(claims)
270
+ return claims
271
+
272
+ def _validate_authorized_party(self, claims: JsonObject) -> None:
273
+ if self._config.client_id is None:
274
+ return
275
+ audiences = self._audiences(claims)
276
+ azp = claims.get("azp")
277
+ if len(audiences) > 1 and azp is None:
278
+ raise OidcTokenValidationError()
279
+ if azp is not None and azp != self._config.client_id:
280
+ raise OidcTokenValidationError()
281
+
282
+ def _audiences(self, claims: JsonObject) -> tuple[str, ...]:
283
+ audience = claims.get("aud")
284
+ if isinstance(audience, str):
285
+ return (audience,)
286
+ if isinstance(audience, list | tuple):
287
+ values: list[str] = []
288
+ for item in audience:
289
+ if not isinstance(item, str):
290
+ raise OidcTokenValidationError()
291
+ values.append(item)
292
+ return tuple(values)
293
+ raise OidcTokenValidationError()
294
+
295
+ def _auth_context_from_claims(self, claims: JsonObject) -> AuthContext:
296
+ subject = AuthSubject(
297
+ id=_required_string(claims, "sub", OidcTokenValidationError),
298
+ display_name=self._optional_string_claim(
299
+ claims,
300
+ self._config.display_name_claim,
301
+ ),
302
+ )
303
+ return AuthContext(
304
+ subject=subject,
305
+ issuer=_required_string(claims, "iss", OidcTokenValidationError),
306
+ tenant=self._optional_string_claim(claims, self._config.tenant_claim),
307
+ roles=self._string_tuple_claim(claims, self._config.roles_claim),
308
+ scopes=self._scope_tuple_claim(claims, self._config.scopes_claim),
309
+ claims=self._retained_claims(claims),
310
+ )
311
+
312
+ def _optional_string_claim(
313
+ self,
314
+ claims: JsonObject,
315
+ claim_name: str | None,
316
+ ) -> str | None:
317
+ if claim_name is None:
318
+ return None
319
+ value = claims.get(claim_name)
320
+ if value is None:
321
+ return None
322
+ if isinstance(value, str):
323
+ return value
324
+ raise OidcTokenValidationError()
325
+
326
+ def _string_tuple_claim(
327
+ self,
328
+ claims: JsonObject,
329
+ claim_name: str,
330
+ ) -> tuple[str, ...]:
331
+ value = claims.get(claim_name)
332
+ if value is None:
333
+ return ()
334
+ if isinstance(value, str):
335
+ return (value,)
336
+ if isinstance(value, list | tuple):
337
+ values: list[str] = []
338
+ for item in value:
339
+ if not isinstance(item, str):
340
+ raise OidcTokenValidationError()
341
+ values.append(item)
342
+ return tuple(values)
343
+ raise OidcTokenValidationError()
344
+
345
+ def _scope_tuple_claim(
346
+ self,
347
+ claims: JsonObject,
348
+ claim_name: str,
349
+ ) -> tuple[str, ...]:
350
+ value = claims.get(claim_name)
351
+ if value is None:
352
+ return ()
353
+ if isinstance(value, str):
354
+ return tuple(scope for scope in value.split(" ") if scope != "")
355
+ return self._string_tuple_claim(claims, claim_name)
356
+
357
+ def _retained_claims(self, claims: JsonObject) -> tuple[AuthClaim, ...]:
358
+ retained: list[AuthClaim] = []
359
+ for name in sorted(self._config.retained_claim_names):
360
+ if name in claims:
361
+ retained.append(
362
+ AuthClaim(
363
+ name=name,
364
+ value=self._retained_claim_value(name, claims[name]),
365
+ )
366
+ )
367
+ return tuple(retained)
368
+
369
+ def _retained_claim_value(self, name: str, value: object) -> AuthClaimValue:
370
+ if name == "aud":
371
+ return self._audience_claim_value(value)
372
+ return self._claim_value(value)
373
+
374
+ def _audience_claim_value(self, value: object) -> AuthClaimValue:
375
+ if isinstance(value, str):
376
+ return value
377
+ if isinstance(value, list | tuple):
378
+ values: list[str] = []
379
+ for item in value:
380
+ if not isinstance(item, str):
381
+ raise OidcTokenValidationError()
382
+ values.append(item)
383
+ return json.dumps(values, separators=(",", ":"))
384
+ raise OidcTokenValidationError()
385
+
386
+ def _claim_value(self, value: object) -> AuthClaimValue:
387
+ if value is None:
388
+ return None
389
+ if isinstance(value, str | int | float | bool):
390
+ return value
391
+ raise OidcTokenValidationError()
392
+
393
+
394
+ def _required_string(
395
+ payload: JsonObject,
396
+ key: str,
397
+ error_type: type[AbstractSpakkyOidcError],
398
+ ) -> str:
399
+ value = payload.get(key)
400
+ if isinstance(value, str) and value != "":
401
+ return value
402
+ raise error_type()
403
+
404
+
405
+ def _string_keyed_dict(payload: dict[object, object]) -> JsonObject:
406
+ result: JsonObject = {}
407
+ for key, value in payload.items():
408
+ if not isinstance(key, str):
409
+ raise OidcTokenValidationError()
410
+ result[key] = value
411
+ return result
412
+
413
+
414
+ @Pod(name="spakky_oidc_auth_provider_contribution")
415
+ def oidc_auth_provider_contribution() -> AuthProviderContribution:
416
+ """Return the auth capabilities contributed by spakky-oidc."""
417
+ return AuthProviderContribution(
418
+ provider_id=OIDC_AUTH_PROVIDER_ID,
419
+ capabilities=frozenset({AuthCapability.AUTHENTICATION}),
420
+ )
File without changes