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/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