simple-module-oidc 0.0.43__py3-none-any.whl
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.
- oidc/__init__.py +1 -0
- oidc/client.py +138 -0
- oidc/constants.py +19 -0
- oidc/contracts/__init__.py +1 -0
- oidc/directory.py +10 -0
- oidc/discovery.py +103 -0
- oidc/endpoints/__init__.py +1 -0
- oidc/endpoints/api.py +119 -0
- oidc/endpoints/views.py +59 -0
- oidc/jwks.py +5 -0
- oidc/locales/de.json +14 -0
- oidc/locales/en.json +14 -0
- oidc/models.py +28 -0
- oidc/module.py +87 -0
- oidc/package.json +7 -0
- oidc/pages/LoggedOut.tsx +15 -0
- oidc/pages/Login.tsx +20 -0
- oidc/persistence.py +28 -0
- oidc/presets.py +59 -0
- oidc/provider.py +172 -0
- oidc/py.typed +0 -0
- oidc/renewal.py +85 -0
- oidc/runtime.py +74 -0
- oidc/settings.py +131 -0
- oidc/state.py +25 -0
- oidc/tokens.py +10 -0
- simple_module_oidc-0.0.43.dist-info/METADATA +231 -0
- simple_module_oidc-0.0.43.dist-info/RECORD +30 -0
- simple_module_oidc-0.0.43.dist-info/WHEEL +4 -0
- simple_module_oidc-0.0.43.dist-info/entry_points.txt +2 -0
oidc/persistence.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Issuer-scoped adapter for shared cache persistence."""
|
|
2
|
+
|
|
3
|
+
from auth.oidc_common.persistence import upsert_user_cache
|
|
4
|
+
from simple_module_db import independent_session
|
|
5
|
+
from starlette.requests import Request
|
|
6
|
+
|
|
7
|
+
from oidc.models import OidcUserCache
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CachePersistence:
|
|
11
|
+
async def _upsert_user_cache(
|
|
12
|
+
self, request: Request, claims: dict, *, throttle_login: bool = False
|
|
13
|
+
) -> str | None:
|
|
14
|
+
try:
|
|
15
|
+
issuer, subject = self._identity(claims)
|
|
16
|
+
except ValueError:
|
|
17
|
+
return None
|
|
18
|
+
name = self._display_name(claims)
|
|
19
|
+
return await upsert_user_cache(
|
|
20
|
+
request,
|
|
21
|
+
model=OidcUserCache,
|
|
22
|
+
identity={"issuer": issuer, "subject": subject},
|
|
23
|
+
email=self._email(claims),
|
|
24
|
+
name=name,
|
|
25
|
+
update_name=bool(name),
|
|
26
|
+
throttle_login=throttle_login,
|
|
27
|
+
session_factory=independent_session,
|
|
28
|
+
)
|
oidc/presets.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Provider presets — per-IdP defaults layered onto OidcSettings.
|
|
2
|
+
|
|
3
|
+
A preset fills in claim names and (optionally) derives the discovery URL so a
|
|
4
|
+
well-known provider needs only a handful of env vars. ``generic`` is the
|
|
5
|
+
fallback for any OIDC-compliant IdP given an explicit discovery URL.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class Preset:
|
|
15
|
+
"""Defaults applied to unset OidcSettings fields for a named provider."""
|
|
16
|
+
|
|
17
|
+
name: str
|
|
18
|
+
# Template filled with ``tenant_id`` to derive the discovery URL when one is
|
|
19
|
+
# not supplied explicitly. Empty ⇒ discovery URL must be configured directly.
|
|
20
|
+
discovery_url_template: str = ""
|
|
21
|
+
uid_claim: str = "sub"
|
|
22
|
+
username_claim: str = "preferred_username"
|
|
23
|
+
email_claim: str = "email"
|
|
24
|
+
name_claim: str = "name"
|
|
25
|
+
roles_claim_path: str = ""
|
|
26
|
+
scope: str = "openid email profile"
|
|
27
|
+
|
|
28
|
+
def discovery_url(self, tenant_id: str) -> str:
|
|
29
|
+
"""Render the discovery URL from ``tenant_id`` (empty if not templated)."""
|
|
30
|
+
if not self.discovery_url_template:
|
|
31
|
+
return ""
|
|
32
|
+
return self.discovery_url_template.format(tenant_id=tenant_id)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
PRESETS: dict[str, Preset] = {
|
|
36
|
+
# Microsoft Entra ID (Azure AD), v2.0 endpoints. Single-tenant: the tenant
|
|
37
|
+
# GUID goes in ``tenant_id`` (a domain name would not match the GUID issuer
|
|
38
|
+
# the discovery document reports). App roles arrive in the
|
|
39
|
+
# ``roles`` claim; ``oid`` is the stable per-tenant user object id.
|
|
40
|
+
"entra": Preset(
|
|
41
|
+
name="entra",
|
|
42
|
+
discovery_url_template=(
|
|
43
|
+
"https://login.microsoftonline.com/{tenant_id}/v2.0/.well-known/openid-configuration"
|
|
44
|
+
),
|
|
45
|
+
uid_claim="oid",
|
|
46
|
+
scope="openid email profile offline_access",
|
|
47
|
+
roles_claim_path="roles",
|
|
48
|
+
),
|
|
49
|
+
# Any OIDC-compliant provider (Auth0, Okta, Zitadel, Authentik, Keycloak, ...)
|
|
50
|
+
# configured with an explicit ``discovery_url``.
|
|
51
|
+
"generic": Preset(name="generic"),
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def get_preset(name: str) -> Preset:
|
|
56
|
+
"""Return a supported preset; typos must not silently change security policy."""
|
|
57
|
+
if name not in PRESETS:
|
|
58
|
+
raise ValueError(f"Unknown OIDC preset: {name}")
|
|
59
|
+
return PRESETS[name]
|
oidc/provider.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""OidcAuthProvider — resolves users from Oidc JWTs or session."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
from auth.contracts.schemas import UserContext
|
|
9
|
+
from auth.contracts.tokens import AUTH_KIND_BEARER, is_api_token, parse_bearer
|
|
10
|
+
from auth.oidc_common.claims import extract_nested as _extract_nested
|
|
11
|
+
from auth.oidc_common.tenant import trusted_tenant, without_tenant_roles
|
|
12
|
+
from simple_module_hosting.session import SESSION_EXPIRES_AT_KEY, session_has_expired
|
|
13
|
+
from starlette.requests import Request
|
|
14
|
+
|
|
15
|
+
from oidc.constants import LOGIN_PATH, LOGOUT_PATH, SESSION_USER_KEY
|
|
16
|
+
from oidc.persistence import CachePersistence
|
|
17
|
+
from oidc.renewal import SessionRenewal
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from oidc.jwks import JWKSCache
|
|
21
|
+
from oidc.settings import OidcSettings
|
|
22
|
+
from oidc.state import OidcState
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class OidcAuthProvider(CachePersistence, SessionRenewal):
|
|
28
|
+
"""Generic OIDC authentication with issuer-scoped identities."""
|
|
29
|
+
|
|
30
|
+
name = "oidc"
|
|
31
|
+
_is_auth_provider = True
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self, settings: OidcSettings | None = None, *, state: OidcState | None = None
|
|
35
|
+
) -> None:
|
|
36
|
+
self._initial_settings = settings
|
|
37
|
+
# The module's state object: a settings save or the boot-time DB
|
|
38
|
+
# hydration swaps ``state.settings``, so reading through it keeps
|
|
39
|
+
# ``trust_tenant_claim`` / ``role_mapping`` edits live.
|
|
40
|
+
self._state = state
|
|
41
|
+
self.jwks_cache: JWKSCache | None = None
|
|
42
|
+
self._warned_tenant_roles: set[str] = set()
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def _settings(self) -> OidcSettings | None:
|
|
46
|
+
live = getattr(self._state, "settings", None)
|
|
47
|
+
return live if live is not None else self._initial_settings
|
|
48
|
+
|
|
49
|
+
async def resolve_user(self, request: Request) -> UserContext | None:
|
|
50
|
+
credential = parse_bearer(request.headers.get("authorization"))
|
|
51
|
+
if credential is not None:
|
|
52
|
+
# An ``api_tokens`` credential is not a JWT: skip the parse and
|
|
53
|
+
# leave it to the principal resolver that owns it.
|
|
54
|
+
if is_api_token(credential):
|
|
55
|
+
return None
|
|
56
|
+
return await self._resolve_bearer(request, credential)
|
|
57
|
+
|
|
58
|
+
session = request.scope.get("session", {})
|
|
59
|
+
if not self._accept_session(session):
|
|
60
|
+
return None
|
|
61
|
+
# Renew authority from the IdP at expiry, without interrupting the request.
|
|
62
|
+
if type(session.get(SESSION_EXPIRES_AT_KEY)) is not int or session_has_expired(session):
|
|
63
|
+
return await self._refresh_session(request)
|
|
64
|
+
ctx = UserContext.from_session_dict(session.get(SESSION_USER_KEY))
|
|
65
|
+
if ctx is not None:
|
|
66
|
+
# The cookie froze what was decided at login; re-decide with the
|
|
67
|
+
# current settings, so turning trust_tenant_claim off takes effect
|
|
68
|
+
# on sessions that already exist.
|
|
69
|
+
ctx.tenant_id = self._trusted_tenant(ctx.tenant_id)
|
|
70
|
+
ctx.roles = self._without_tenant_roles(ctx.roles)
|
|
71
|
+
return ctx
|
|
72
|
+
|
|
73
|
+
def get_login_url(self, request: Request | None, next_url: str | None = None) -> str:
|
|
74
|
+
return LOGIN_PATH
|
|
75
|
+
|
|
76
|
+
def get_logout_url(self, request: Request | None) -> str:
|
|
77
|
+
return LOGOUT_PATH
|
|
78
|
+
|
|
79
|
+
def is_bearer_request(self, request: Request | None) -> bool:
|
|
80
|
+
if request is None:
|
|
81
|
+
return False
|
|
82
|
+
return parse_bearer(request.headers.get("authorization")) is not None
|
|
83
|
+
|
|
84
|
+
async def _resolve_bearer(self, request: Request, token: str) -> UserContext | None:
|
|
85
|
+
if self.jwks_cache is None or not self._settings or not self._settings.api_audience:
|
|
86
|
+
logger.debug("OIDC bearer authentication unavailable")
|
|
87
|
+
return None
|
|
88
|
+
claims = await self.jwks_cache.validate_jwt(token, audience=self._settings.api_audience)
|
|
89
|
+
if claims is None or not self._allowed_api_client(claims):
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
cache_id = await self._upsert_user_cache(request, claims, throttle_login=True)
|
|
93
|
+
if cache_id is None:
|
|
94
|
+
return None
|
|
95
|
+
ctx = self._claims_to_user_context(claims, cache_id=cache_id)
|
|
96
|
+
# Full rights, but a bearer credential: it may not manage API tokens
|
|
97
|
+
# (``auth.contracts.tokens.AUTH_KIND_BEARER``). The browser sign-in
|
|
98
|
+
# builds the same context from the callback and keeps ``"session"``.
|
|
99
|
+
ctx.auth_kind = AUTH_KIND_BEARER
|
|
100
|
+
return ctx
|
|
101
|
+
|
|
102
|
+
def _allowed_api_client(self, claims: dict[str, Any]) -> bool:
|
|
103
|
+
"""Whether the client that obtained this API token may call us.
|
|
104
|
+
|
|
105
|
+
Fails closed: a token naming no client (no ``azp``/``appid``) is refused.
|
|
106
|
+
"""
|
|
107
|
+
s = self._settings
|
|
108
|
+
client = claims.get("azp") or claims.get("appid")
|
|
109
|
+
if not isinstance(client, str) or not client or not s:
|
|
110
|
+
return False
|
|
111
|
+
if client == s.client_id or client in s.api_allowed_client_ids:
|
|
112
|
+
return True
|
|
113
|
+
logger.info("OIDC bearer token from a client not in api_allowed_client_ids")
|
|
114
|
+
return False
|
|
115
|
+
|
|
116
|
+
def _claims_to_user_context(
|
|
117
|
+
self,
|
|
118
|
+
claims: dict[str, Any],
|
|
119
|
+
*,
|
|
120
|
+
cache_id: str,
|
|
121
|
+
) -> UserContext:
|
|
122
|
+
s = self._settings
|
|
123
|
+
roles_raw = _extract_nested(claims, s.roles_claim_path) if s else None
|
|
124
|
+
mapping = s.role_mapping if s else {}
|
|
125
|
+
mapped = [mapping[r] for r in (roles_raw or []) if isinstance(r, str) and r in mapping]
|
|
126
|
+
return UserContext(
|
|
127
|
+
id=cache_id,
|
|
128
|
+
email=self._email(claims) or "",
|
|
129
|
+
email_verified=bool(
|
|
130
|
+
(not s or s.provider != "entra") and claims.get("email_verified") is True
|
|
131
|
+
),
|
|
132
|
+
name=self._display_name(claims),
|
|
133
|
+
roles=self._without_tenant_roles(mapped),
|
|
134
|
+
tenant_id=self._trusted_tenant(claims.get("tenant_id")),
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
def _email(self, claims: dict[str, Any]) -> str | None:
|
|
138
|
+
"""The configured email claim, or ``None`` when absent or not a string."""
|
|
139
|
+
value = claims.get(self._settings.email_claim if self._settings else "email")
|
|
140
|
+
return value if isinstance(value, str) else None
|
|
141
|
+
|
|
142
|
+
def _display_name(self, claims: dict[str, Any]) -> str:
|
|
143
|
+
s = self._settings
|
|
144
|
+
for name in (
|
|
145
|
+
s.username_claim if s else "preferred_username",
|
|
146
|
+
s.name_claim if s else "name",
|
|
147
|
+
):
|
|
148
|
+
value = claims.get(name)
|
|
149
|
+
if isinstance(value, str) and value:
|
|
150
|
+
return value
|
|
151
|
+
return ""
|
|
152
|
+
|
|
153
|
+
def _subject(self, claims: dict[str, Any]) -> str:
|
|
154
|
+
key = self._settings.uid_claim if self._settings else "sub"
|
|
155
|
+
subject = claims.get(key)
|
|
156
|
+
if not isinstance(subject, str) or not subject.strip():
|
|
157
|
+
raise ValueError("Missing configured OIDC identity claim")
|
|
158
|
+
return subject
|
|
159
|
+
|
|
160
|
+
def _identity(self, claims: dict[str, Any]) -> tuple[str, str]:
|
|
161
|
+
issuer = claims.get("iss")
|
|
162
|
+
if not isinstance(issuer, str) or not issuer:
|
|
163
|
+
raise ValueError("Missing OIDC issuer")
|
|
164
|
+
return issuer, self._subject(claims)
|
|
165
|
+
|
|
166
|
+
def _trusted_tenant(self, value: Any) -> str | None:
|
|
167
|
+
return trusted_tenant(
|
|
168
|
+
value, trust_claim=bool(self._settings and self._settings.trust_tenant_claim)
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
def _without_tenant_roles(self, roles: list[str]) -> list[str]:
|
|
172
|
+
return without_tenant_roles(roles, warned=self._warned_tenant_roles, provider="Oidc")
|
oidc/py.typed
ADDED
|
File without changes
|
oidc/renewal.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""OIDC identity-token adapter for shared browser renewal."""
|
|
2
|
+
|
|
3
|
+
from auth.oidc_common.renewal import refresh_session
|
|
4
|
+
from auth.oidc_common.session import SessionKeys, accept_authority, authority_marker
|
|
5
|
+
|
|
6
|
+
from oidc.constants import (
|
|
7
|
+
SESSION_IDENTITY_KEY,
|
|
8
|
+
SESSION_REFRESH_RETRY_AT_KEY,
|
|
9
|
+
SESSION_REFRESH_TOKEN_KEY,
|
|
10
|
+
SESSION_USER_KEY,
|
|
11
|
+
)
|
|
12
|
+
from oidc.runtime import require_client
|
|
13
|
+
from oidc.tokens import open_refresh_token, store_refresh_token
|
|
14
|
+
|
|
15
|
+
_KEYS = SessionKeys(
|
|
16
|
+
SESSION_USER_KEY,
|
|
17
|
+
SESSION_REFRESH_TOKEN_KEY,
|
|
18
|
+
SESSION_REFRESH_RETRY_AT_KEY,
|
|
19
|
+
"oidc_refresh_attempted",
|
|
20
|
+
identity=SESSION_IDENTITY_KEY,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SessionRenewal:
|
|
25
|
+
_forget_session = staticmethod(_KEYS.forget)
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def _configured_issuer(self) -> str:
|
|
29
|
+
s = self._settings
|
|
30
|
+
return s.discovery_url.replace("/.well-known/openid-configuration", "", 1) if s else ""
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def _session_authority(self) -> str:
|
|
34
|
+
s = self._settings
|
|
35
|
+
return authority_marker(self.name, self._configured_issuer, s.client_id if s else "")
|
|
36
|
+
|
|
37
|
+
def _accept_session(self, session) -> bool:
|
|
38
|
+
return accept_authority(
|
|
39
|
+
session,
|
|
40
|
+
self._session_authority,
|
|
41
|
+
(SESSION_IDENTITY_KEY,),
|
|
42
|
+
SESSION_USER_KEY,
|
|
43
|
+
legacy_ok=lambda: self._legacy_identity_matches(session),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
def _legacy_identity_matches(self, session) -> bool:
|
|
47
|
+
identity = session.get(SESSION_IDENTITY_KEY)
|
|
48
|
+
if (
|
|
49
|
+
not isinstance(identity, list)
|
|
50
|
+
or len(identity) != 2
|
|
51
|
+
or not self.jwks_cache
|
|
52
|
+
or not self._settings
|
|
53
|
+
):
|
|
54
|
+
return False
|
|
55
|
+
issuer = self.jwks_cache.issuer
|
|
56
|
+
configured = self._configured_issuer
|
|
57
|
+
# Discovery tolerates a trailing slash; token issuers must match exactly.
|
|
58
|
+
return issuer.rstrip("/") == configured.rstrip("/") and identity[0] == issuer
|
|
59
|
+
|
|
60
|
+
async def _refresh_session(self, request):
|
|
61
|
+
async def refresh(token):
|
|
62
|
+
return await (await require_client(request)).refresh_tokens(token)
|
|
63
|
+
|
|
64
|
+
async def validate(tokens):
|
|
65
|
+
return await self.jwks_cache.validate_jwt(
|
|
66
|
+
tokens.get("id_token", ""), audience=self._settings.client_id
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
async def matches(claims, old_ctx):
|
|
70
|
+
try:
|
|
71
|
+
return list(self._identity(claims)) == request.session.get(SESSION_IDENTITY_KEY)
|
|
72
|
+
except ValueError:
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
return await refresh_session(
|
|
76
|
+
request,
|
|
77
|
+
keys=_KEYS,
|
|
78
|
+
cache=self.jwks_cache,
|
|
79
|
+
open_token=open_refresh_token,
|
|
80
|
+
store_token=store_refresh_token,
|
|
81
|
+
refresh=refresh,
|
|
82
|
+
validate=validate,
|
|
83
|
+
matches_identity=matches,
|
|
84
|
+
to_context=self._claims_to_user_context,
|
|
85
|
+
)
|
oidc/runtime.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Initialize one coherent discovery/client/JWKS configuration per process."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
from fastapi import HTTPException, Request
|
|
6
|
+
|
|
7
|
+
from oidc.client import OIDCClient
|
|
8
|
+
from oidc.constants import SESSION_USER_KEY
|
|
9
|
+
from oidc.discovery import fetch_metadata
|
|
10
|
+
from oidc.jwks import JWKSCache
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
_DISCOVERY_SUFFIX = "/.well-known/openid-configuration"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
async def initialize(app) -> bool:
|
|
17
|
+
state = app.state.oidc
|
|
18
|
+
development = app.state.sm.settings.environment == "development"
|
|
19
|
+
async with state.initialization_lock:
|
|
20
|
+
if state.client is not None:
|
|
21
|
+
return True
|
|
22
|
+
s = state.settings
|
|
23
|
+
try:
|
|
24
|
+
if not s.discovery_url or not s.client_id:
|
|
25
|
+
raise ValueError("OIDC discovery URL and client ID are required")
|
|
26
|
+
if _DISCOVERY_SUFFIX not in s.discovery_url:
|
|
27
|
+
raise ValueError("OIDC discovery URL must identify its issuer")
|
|
28
|
+
# OIDC discovery uses either issuer + suffix or suffix + issuer path.
|
|
29
|
+
prefix, suffix = s.discovery_url.split(_DISCOVERY_SUFFIX, 1)
|
|
30
|
+
expected_issuer = prefix + suffix
|
|
31
|
+
metadata = await fetch_metadata(
|
|
32
|
+
s.discovery_url, expected_issuer=expected_issuer, development=development
|
|
33
|
+
)
|
|
34
|
+
cache = JWKSCache(
|
|
35
|
+
metadata.jwks_uri,
|
|
36
|
+
ttl_seconds=s.jwks_cache_ttl_seconds,
|
|
37
|
+
issuer=metadata.issuer,
|
|
38
|
+
audience=s.client_id,
|
|
39
|
+
)
|
|
40
|
+
client = OIDCClient(
|
|
41
|
+
authorization_endpoint=metadata.authorization_endpoint,
|
|
42
|
+
token_endpoint=metadata.token_endpoint,
|
|
43
|
+
end_session_endpoint=metadata.end_session_endpoint,
|
|
44
|
+
revocation_endpoint=metadata.revocation_endpoint,
|
|
45
|
+
token_endpoint_auth_methods_supported=metadata.token_endpoint_auth_methods_supported,
|
|
46
|
+
revocation_endpoint_auth_methods_supported=(
|
|
47
|
+
metadata.revocation_endpoint_auth_methods_supported
|
|
48
|
+
),
|
|
49
|
+
client_id=s.client_id,
|
|
50
|
+
client_secret=s.client_secret,
|
|
51
|
+
)
|
|
52
|
+
except Exception as exc:
|
|
53
|
+
if not development:
|
|
54
|
+
raise RuntimeError("OIDC discovery failed; authentication unavailable") from exc
|
|
55
|
+
logger.warning("OIDC discovery unavailable; login will retry")
|
|
56
|
+
return False
|
|
57
|
+
state.metadata, state.jwks_cache, state.client = metadata, cache, client
|
|
58
|
+
app.state.auth.auth_provider.jwks_cache = cache
|
|
59
|
+
return True
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def require_client(request: Request) -> OIDCClient:
|
|
63
|
+
"""The configured client, initialising lazily; 503 when the provider is unavailable."""
|
|
64
|
+
if request.app.state.oidc.client is None:
|
|
65
|
+
await initialize(request.app)
|
|
66
|
+
client = request.app.state.oidc.client
|
|
67
|
+
if client is None:
|
|
68
|
+
raise HTTPException(status_code=503, detail="OIDC provider not configured")
|
|
69
|
+
return client
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def is_signed_in(session) -> bool:
|
|
73
|
+
"""Whether the browser session already holds an OIDC sign-in."""
|
|
74
|
+
return isinstance(session.get(SESSION_USER_KEY), dict)
|
oidc/settings.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""OIDC module settings -- DB-backed via ``register_module_settings``.
|
|
2
|
+
|
|
3
|
+
Configuration is preset-driven: pick a ``provider`` preset (e.g. ``entra``) and
|
|
4
|
+
supply the few required secrets; the preset fills claim defaults and derives the
|
|
5
|
+
discovery URL. ``generic`` works with any OIDC provider given a ``discovery_url``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
|
|
12
|
+
from pydantic import Field, field_validator, model_validator
|
|
13
|
+
from pydantic_settings import SettingsConfigDict
|
|
14
|
+
from simple_module_core.dotenv import env_str
|
|
15
|
+
from simple_module_core.environments import NON_PROD_ENVIRONMENTS
|
|
16
|
+
from simple_module_core.redirect_safety import non_empty_redirect
|
|
17
|
+
from simple_module_core.settings_base import DbBackedSettings
|
|
18
|
+
from simple_module_core.stored_settings import field_error
|
|
19
|
+
|
|
20
|
+
from oidc.constants import DEFAULT_LOGIN_REDIRECT_URL
|
|
21
|
+
from oidc.presets import get_preset
|
|
22
|
+
|
|
23
|
+
# Sentinel for "unset by the user" so the preset can supply a default while still
|
|
24
|
+
# letting an explicit empty/other value win.
|
|
25
|
+
_UNSET = "__unset__"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class OidcSettings(DbBackedSettings):
|
|
29
|
+
"""Generic OIDC configuration with per-provider presets."""
|
|
30
|
+
|
|
31
|
+
model_config = SettingsConfigDict(extra="ignore")
|
|
32
|
+
|
|
33
|
+
provider: str = Field(
|
|
34
|
+
default=env_str("SM_OIDC_PROVIDER", "generic"), json_schema_extra={"requires_restart": True}
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
# Endpoint discovery. ``discovery_url`` wins; otherwise the preset derives it
|
|
38
|
+
# from ``tenant_id`` (Entra). The well-known doc supplies all endpoints + issuer.
|
|
39
|
+
discovery_url: str = Field(
|
|
40
|
+
default=env_str("SM_OIDC_DISCOVERY_URL", ""), json_schema_extra={"requires_restart": True}
|
|
41
|
+
)
|
|
42
|
+
tenant_id: str = Field(
|
|
43
|
+
default=env_str("SM_OIDC_TENANT_ID", ""), json_schema_extra={"requires_restart": True}
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
client_id: str = Field(
|
|
47
|
+
default=env_str("SM_OIDC_CLIENT_ID", ""), json_schema_extra={"requires_restart": True}
|
|
48
|
+
)
|
|
49
|
+
client_secret: str = Field(
|
|
50
|
+
default=env_str("SM_OIDC_CLIENT_SECRET", ""), json_schema_extra={"requires_restart": True}
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# Bearer authentication is opt-in; browser ID tokens always use client_id.
|
|
54
|
+
api_audience: str = ""
|
|
55
|
+
# Clients (``azp``, or ``appid`` on Entra v1 tokens) allowed to call the API
|
|
56
|
+
# with a bearer token, besides ``client_id``. An API token is minted for our
|
|
57
|
+
# audience but by any client the IdP lets ask for it, so name each one.
|
|
58
|
+
api_allowed_client_ids: list[str] = Field(
|
|
59
|
+
default_factory=list,
|
|
60
|
+
description=(
|
|
61
|
+
"Client IDs (token azp, or appid on Entra v1) besides client_id allowed to call "
|
|
62
|
+
"the API with a bearer token. Empty: only this app's own client."
|
|
63
|
+
),
|
|
64
|
+
)
|
|
65
|
+
trust_tenant_claim: bool = False
|
|
66
|
+
|
|
67
|
+
# Claim mapping. ``_UNSET`` defaults are replaced by the preset's value.
|
|
68
|
+
scope: str = _UNSET
|
|
69
|
+
uid_claim: str = _UNSET
|
|
70
|
+
username_claim: str = _UNSET
|
|
71
|
+
email_claim: str = _UNSET
|
|
72
|
+
name_claim: str = _UNSET
|
|
73
|
+
# Env-seedable: the first admin can only sign in once roles are mapped, and
|
|
74
|
+
# the Settings UI that edits this needs an admin. A stored value still wins.
|
|
75
|
+
roles_claim_path: str = env_str("SM_OIDC_ROLES_CLAIM_PATH", _UNSET)
|
|
76
|
+
|
|
77
|
+
login_redirect_url: str = DEFAULT_LOGIN_REDIRECT_URL
|
|
78
|
+
jwks_cache_ttl_seconds: int = Field(
|
|
79
|
+
default=3600, ge=0, json_schema_extra={"requires_restart": True}
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
role_mapping: dict[str, str] = Field(
|
|
83
|
+
default_factory=lambda: {"admin": "admin", "user": "user"},
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
@field_validator("login_redirect_url")
|
|
87
|
+
@classmethod
|
|
88
|
+
def _non_empty_redirect(cls, value: str) -> str:
|
|
89
|
+
return non_empty_redirect(value, default=DEFAULT_LOGIN_REDIRECT_URL)
|
|
90
|
+
|
|
91
|
+
@model_validator(mode="after")
|
|
92
|
+
def _apply_preset(self) -> OidcSettings:
|
|
93
|
+
preset = get_preset(self.provider)
|
|
94
|
+
if self.provider == "entra":
|
|
95
|
+
from urllib.parse import urlsplit
|
|
96
|
+
|
|
97
|
+
forbidden = {"common", "organizations", "consumers"}
|
|
98
|
+
segments = urlsplit(self.discovery_url).path.lower().split("/")
|
|
99
|
+
if self.tenant_id.strip().lower() in forbidden or forbidden.intersection(segments):
|
|
100
|
+
raise ValueError("Entra supports single-tenant configuration only")
|
|
101
|
+
for name in (
|
|
102
|
+
"uid_claim",
|
|
103
|
+
"username_claim",
|
|
104
|
+
"email_claim",
|
|
105
|
+
"name_claim",
|
|
106
|
+
"roles_claim_path",
|
|
107
|
+
"scope",
|
|
108
|
+
):
|
|
109
|
+
if getattr(self, name) == _UNSET:
|
|
110
|
+
setattr(self, name, getattr(preset, name))
|
|
111
|
+
if not self.discovery_url and self.tenant_id:
|
|
112
|
+
self.discovery_url = preset.discovery_url(self.tenant_id)
|
|
113
|
+
return self
|
|
114
|
+
|
|
115
|
+
@model_validator(mode="after")
|
|
116
|
+
def _check_required_in_production(self) -> OidcSettings:
|
|
117
|
+
env = os.environ.get("SM_ENVIRONMENT", "development")
|
|
118
|
+
if env in NON_PROD_ENVIRONMENTS:
|
|
119
|
+
return self
|
|
120
|
+
required = ("discovery_url", "client_id", "client_secret")
|
|
121
|
+
missing = [field for field in required if not getattr(self, field)]
|
|
122
|
+
if missing:
|
|
123
|
+
raise field_error(
|
|
124
|
+
type(self),
|
|
125
|
+
missing[0],
|
|
126
|
+
"oidc_settings_required",
|
|
127
|
+
f"OIDC settings required in production: {', '.join(missing)}",
|
|
128
|
+
value="",
|
|
129
|
+
related=list(required),
|
|
130
|
+
)
|
|
131
|
+
return self
|
oidc/state.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Module-scoped state container for the oidc module."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from oidc.client import OIDCClient
|
|
11
|
+
from oidc.discovery import OidcMetadata
|
|
12
|
+
from oidc.jwks import JWKSCache
|
|
13
|
+
from oidc.settings import OidcSettings
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class OidcState:
|
|
18
|
+
"""OIDC-module singletons. Single slot at ``app.state.oidc``."""
|
|
19
|
+
|
|
20
|
+
settings: OidcSettings
|
|
21
|
+
metadata: OidcMetadata | None = None
|
|
22
|
+
jwks_cache: JWKSCache | None = None
|
|
23
|
+
client: OIDCClient | None = None
|
|
24
|
+
|
|
25
|
+
initialization_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
oidc/tokens.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Provider-scoped refresh-token encryption; preserves existing session ciphertext."""
|
|
2
|
+
|
|
3
|
+
from auth.oidc_common.tokens import provider_tokens
|
|
4
|
+
|
|
5
|
+
from oidc.constants import SESSION_REFRESH_TOKEN_KEY
|
|
6
|
+
|
|
7
|
+
_TOKENS = provider_tokens(info=b"oidc-refresh-token", session_key=SESSION_REFRESH_TOKEN_KEY)
|
|
8
|
+
seal_refresh_token = _TOKENS.seal_refresh_token
|
|
9
|
+
open_refresh_token = _TOKENS.open_refresh_token
|
|
10
|
+
store_refresh_token = _TOKENS.store_refresh_token
|