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.
- spakky_oidc-6.5.0/PKG-INFO +83 -0
- spakky_oidc-6.5.0/README.md +70 -0
- spakky_oidc-6.5.0/pyproject.toml +78 -0
- spakky_oidc-6.5.0/src/spakky/plugins/oidc/__init__.py +39 -0
- spakky_oidc-6.5.0/src/spakky/plugins/oidc/contributions/__init__.py +1 -0
- spakky_oidc-6.5.0/src/spakky/plugins/oidc/contributions/auth.py +9 -0
- spakky_oidc-6.5.0/src/spakky/plugins/oidc/error.py +39 -0
- spakky_oidc-6.5.0/src/spakky/plugins/oidc/main.py +9 -0
- spakky_oidc-6.5.0/src/spakky/plugins/oidc/provider.py +420 -0
- spakky_oidc-6.5.0/src/spakky/plugins/oidc/py.typed +0 -0
|
@@ -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
|