diff --git a/CHANGELOG.md b/CHANGELOG.md index 12e20c5b9..ac2210466 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,7 @@ All notable changes to this project are documented in this file. The format is b - `dashboard` follows the module layout: `dashboard.core.constants`, `dashboard.web.{endpoints,stats,doctor}`, and the page components move from `pages/components/` to `ui/components/`. Nothing outside the module imported the old paths, so no aliases remain. - `feature_flags` follows the module layout: `feature_flags.core.{constants,service,invalidation,tenant_cleanup}`, `feature_flags.web.{deps,endpoints}`, and the page components and types move to `ui/`. `feature_flags.constants` still imports, with a `DeprecationWarning`, until 0.0.46. - `keycloak` follows the module layout: `keycloak.core.{constants,settings,state}`, `keycloak.identity.{provider,oidc,renewal,directory,persistence,tokens,jwks}` and `keycloak.web.endpoints`. `keycloak.{constants,settings,provider,jwks,tokens}` still import, with a `DeprecationWarning`, until 0.0.46. +- `oidc` follows the module layout: `oidc.core.{constants,settings,presets,state,runtime}`, `oidc.identity.{provider,client,discovery,renewal,directory,persistence,tokens,jwks}` and `oidc.web.endpoints`. `oidc.{settings,provider,jwks}` still import, with a `DeprecationWarning`, until 0.0.46; other old `oidc.*` paths are gone. ### Fixed diff --git a/docs/module-layout.md b/docs/module-layout.md index 5988477a3..edb2672e2 100644 --- a/docs/module-layout.md +++ b/docs/module-layout.md @@ -33,6 +33,7 @@ checkers, not a framework runtime lookup. | `module.py` | `framework/core/simple_module_core/discovery.py:80` loads the `simple_module` entry point; the scaffold declares its target in `framework/cli/simple_module_cli/templates/module/pyproject.toml.tpl:15`. | | `models.py` or `models/` | `framework/db/simple_module_db/migrations.py:74` imports the module's models for metadata; `framework/core/simple_module_core/diagnostics/_tenancy.py:94` and `framework/core/simple_module_core/diagnostics/_expression_index.py:52` do the same. A `models/` package must load its tables. | | `tasks.py` | `modules/background_tasks/background_tasks/celery_app.py:215` autodiscovers with `related_name="tasks"`; `framework/core/simple_module_core/diagnostics/_heavy_task_imports.py:65` checks this root file (SM028). | +| `task_config.py` | `modules/background_tasks/background_tasks/task_routing.py:46` imports `.task_config` for a module's Celery routes and beat schedule. | | `contracts/` | Public imports for sibling modules; the scaffold consumer is `framework/cli/simple_module_cli/templates/module/__PACKAGE__/web/service.py.tpl:15`. Keep public SQLModel DTOs, events and real extension contracts here. | | `pages/` | `framework/hosting/simple_module_hosting/manifest.py:97` discovers pages; `framework/hosting/simple_module_hosting/page_globs.py:34` emits their Vite glob; `framework/core/simple_module_core/diagnostics/_pages.py:170` checks page names. | | `pages/api.ts`, `pages/api.generated.ts` | `framework/cli/simple_module_cli/openapi_types.py:130` selects the root pages directory for generation; `framework/cli/simple_module_cli/templates/module/__PACKAGE__/pages/api.ts.tpl:8` consumes the generated types. | @@ -215,7 +216,7 @@ when a later module PR moves files. | file_storage | 39 | 65 | flat | | keycloak | 3 (+5 shims) | 0 | follows this layout | | mail | 2 (+2 shims) | 0 | follows this layout | -| oidc | 16 | 24 | flat | +| oidc | 3 (+3 shims) | 0 | follows this layout | | permissions | 12 | 14 | flat | | settings | 31 | 43 | flat | | sharing | 3 (+3 shims) | 0 | follows this layout | diff --git a/host/tests/test_module_layout.py b/host/tests/test_module_layout.py index 83ffe85e1..f39e9880d 100644 --- a/host/tests/test_module_layout.py +++ b/host/tests/test_module_layout.py @@ -19,6 +19,7 @@ "models.py", "models", "tasks.py", + "task_config.py", "contracts", "pages", "locales", @@ -34,6 +35,7 @@ DOMAIN_AREAS: dict[str, frozenset[str]] = { "keycloak": frozenset({"identity"}), "mail": frozenset({"delivery"}), + "oidc": frozenset({"identity"}), } # Frozen inventory, not a computed baseline: moves must remove their old entries. @@ -85,12 +87,6 @@ uploads.py visibility.py worker.py worker_support.py """.split() ), - "oidc": frozenset( - """ - client.py constants.py directory.py discovery.py endpoints jwks.py persistence.py - presets.py provider.py renewal.py runtime.py settings.py state.py tokens.py - """.split() - ), "permissions": frozenset( """ _registry_sync.py constants.py deps.py endpoints grants.py group_labels.py diff --git a/modules/auth/tests/conftest.py b/modules/auth/tests/conftest.py index 30cdaf081..59fe4ab0a 100644 --- a/modules/auth/tests/conftest.py +++ b/modules/auth/tests/conftest.py @@ -27,7 +27,7 @@ async def renewal(request, tmp_path): await conn.run_sync(Base.metadata.create_all) await conn.run_sync(LeaseRow.metadata.create_all) - tokens = import_module(f"{request.param}.tokens") + tokens = import_module(f"{request.param}.identity.tokens") keys = SessionKeys("user_ctx", f"{request.param}_refresh_token", "retry", "attempted") app = SimpleNamespace( state=SimpleNamespace( diff --git a/modules/auth/tests/test_auth_oidc_authority.py b/modules/auth/tests/test_auth_oidc_authority.py index 6b1d26898..e7b9f81e4 100644 --- a/modules/auth/tests/test_auth_oidc_authority.py +++ b/modules/auth/tests/test_auth_oidc_authority.py @@ -9,8 +9,8 @@ from auth.contracts.schemas import UserContext from keycloak.core.settings import KeycloakSettings from keycloak.identity.provider import KeycloakAuthProvider -from oidc.provider import OidcAuthProvider -from oidc.settings import OidcSettings +from oidc.core.settings import OidcSettings +from oidc.identity.provider import OidcAuthProvider from simple_module_hosting.session import SessionMiddleware from starlette.requests import Request diff --git a/modules/auth/tests/test_auth_oidc_jwks_cache.py b/modules/auth/tests/test_auth_oidc_jwks_cache.py index 7f37484f1..286478572 100644 --- a/modules/auth/tests/test_auth_oidc_jwks_cache.py +++ b/modules/auth/tests/test_auth_oidc_jwks_cache.py @@ -10,7 +10,7 @@ from cryptography.hazmat.primitives.asymmetric import rsa from jwt.algorithms import RSAAlgorithm from keycloak.identity.jwks import JWKSCache as KeycloakJWKS -from oidc.jwks import JWKSCache as OidcJWKS +from oidc.identity.jwks import JWKSCache as OidcJWKS @pytest.fixture(params=[KeycloakJWKS, OidcJWKS]) diff --git a/modules/auth/tests/test_auth_oidc_jwt_hardening.py b/modules/auth/tests/test_auth_oidc_jwt_hardening.py index 0bb3fa64c..825894b2e 100644 --- a/modules/auth/tests/test_auth_oidc_jwt_hardening.py +++ b/modules/auth/tests/test_auth_oidc_jwt_hardening.py @@ -6,7 +6,7 @@ import pytest from cryptography.hazmat.primitives.asymmetric import rsa from keycloak.identity.jwks import JWKSCache as KeycloakJWKS -from oidc.jwks import JWKSCache as OidcJWKS +from oidc.identity.jwks import JWKSCache as OidcJWKS @pytest.fixture(params=[KeycloakJWKS, OidcJWKS]) diff --git a/modules/auth/tests/test_auth_oidc_tokens.py b/modules/auth/tests/test_auth_oidc_tokens.py index 993cdb17d..e236b504b 100644 --- a/modules/auth/tests/test_auth_oidc_tokens.py +++ b/modules/auth/tests/test_auth_oidc_tokens.py @@ -8,7 +8,7 @@ from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.kdf.hkdf import HKDF from keycloak.identity import tokens as keycloak_tokens -from oidc import tokens as oidc_tokens +from oidc.identity import tokens as oidc_tokens @pytest.fixture(params=[("keycloak", keycloak_tokens), ("oidc", oidc_tokens)]) diff --git a/modules/keycloak/tests/core/module/test_keycloak_compat_741.py b/modules/keycloak/tests/core/module/test_keycloak_compat_741.py index 94dae0a7e..00267bf51 100644 --- a/modules/keycloak/tests/core/module/test_keycloak_compat_741.py +++ b/modules/keycloak/tests/core/module/test_keycloak_compat_741.py @@ -16,7 +16,7 @@ from keycloak.identity.tokens import seal_refresh_token from keycloak.module import KeycloakModule from keycloak.web.endpoints import api -from oidc.jwks import JWKSCache as OidcJWKS +from oidc.identity.jwks import JWKSCache as OidcJWKS from starlette.requests import Request _KEY = rsa.generate_private_key(public_exponent=65537, key_size=2048) diff --git a/modules/oidc/oidc/core/__init__.py b/modules/oidc/oidc/core/__init__.py new file mode 100644 index 000000000..0ef65189f --- /dev/null +++ b/modules/oidc/oidc/core/__init__.py @@ -0,0 +1,4 @@ +"""What the OIDC provider builds on. + +Constants, settings, presets, app state and the app-free runtime. +""" diff --git a/modules/oidc/oidc/constants.py b/modules/oidc/oidc/core/constants.py similarity index 100% rename from modules/oidc/oidc/constants.py rename to modules/oidc/oidc/core/constants.py diff --git a/modules/oidc/oidc/presets.py b/modules/oidc/oidc/core/presets.py similarity index 100% rename from modules/oidc/oidc/presets.py rename to modules/oidc/oidc/core/presets.py diff --git a/modules/oidc/oidc/runtime.py b/modules/oidc/oidc/core/runtime.py similarity index 94% rename from modules/oidc/oidc/runtime.py rename to modules/oidc/oidc/core/runtime.py index 047efb797..7e50ae4ec 100644 --- a/modules/oidc/oidc/runtime.py +++ b/modules/oidc/oidc/core/runtime.py @@ -4,10 +4,10 @@ from fastapi import HTTPException, Request -from oidc.client import OIDCClient -from oidc.constants import SESSION_USER_KEY -from oidc.discovery import fetch_metadata -from oidc.jwks import JWKSCache +from oidc.core.constants import SESSION_USER_KEY +from oidc.identity.client import OIDCClient +from oidc.identity.discovery import fetch_metadata +from oidc.identity.jwks import JWKSCache logger = logging.getLogger(__name__) _DISCOVERY_SUFFIX = "/.well-known/openid-configuration" diff --git a/modules/oidc/oidc/core/settings.py b/modules/oidc/oidc/core/settings.py new file mode 100644 index 000000000..952355fdf --- /dev/null +++ b/modules/oidc/oidc/core/settings.py @@ -0,0 +1,131 @@ +"""OIDC module settings -- DB-backed via ``register_module_settings``. + +Configuration is preset-driven: pick a ``provider`` preset (e.g. ``entra``) and +supply the few required secrets; the preset fills claim defaults and derives the +discovery URL. ``generic`` works with any OIDC provider given a ``discovery_url``. +""" + +from __future__ import annotations + +import os + +from pydantic import Field, field_validator, model_validator +from pydantic_settings import SettingsConfigDict +from simple_module_core.dotenv import env_str +from simple_module_core.environments import NON_PROD_ENVIRONMENTS +from simple_module_core.redirect_safety import non_empty_redirect +from simple_module_core.settings_base import DbBackedSettings +from simple_module_core.stored_settings import field_error + +from oidc.core.constants import DEFAULT_LOGIN_REDIRECT_URL +from oidc.core.presets import get_preset + +# Sentinel for "unset by the user" so the preset can supply a default while still +# letting an explicit empty/other value win. +_UNSET = "__unset__" + + +class OidcSettings(DbBackedSettings): + """Generic OIDC configuration with per-provider presets.""" + + model_config = SettingsConfigDict(extra="ignore") + + provider: str = Field( + default=env_str("SM_OIDC_PROVIDER", "generic"), json_schema_extra={"requires_restart": True} + ) + + # Endpoint discovery. ``discovery_url`` wins; otherwise the preset derives it + # from ``tenant_id`` (Entra). The well-known doc supplies all endpoints + issuer. + discovery_url: str = Field( + default=env_str("SM_OIDC_DISCOVERY_URL", ""), json_schema_extra={"requires_restart": True} + ) + tenant_id: str = Field( + default=env_str("SM_OIDC_TENANT_ID", ""), json_schema_extra={"requires_restart": True} + ) + + client_id: str = Field( + default=env_str("SM_OIDC_CLIENT_ID", ""), json_schema_extra={"requires_restart": True} + ) + client_secret: str = Field( + default=env_str("SM_OIDC_CLIENT_SECRET", ""), json_schema_extra={"requires_restart": True} + ) + + # Bearer authentication is opt-in; browser ID tokens always use client_id. + api_audience: str = "" + # Clients (``azp``, or ``appid`` on Entra v1 tokens) allowed to call the API + # with a bearer token, besides ``client_id``. An API token is minted for our + # audience but by any client the IdP lets ask for it, so name each one. + api_allowed_client_ids: list[str] = Field( + default_factory=list, + description=( + "Client IDs (token azp, or appid on Entra v1) besides client_id allowed to call " + "the API with a bearer token. Empty: only this app's own client." + ), + ) + trust_tenant_claim: bool = False + + # Claim mapping. ``_UNSET`` defaults are replaced by the preset's value. + scope: str = _UNSET + uid_claim: str = _UNSET + username_claim: str = _UNSET + email_claim: str = _UNSET + name_claim: str = _UNSET + # Env-seedable: the first admin can only sign in once roles are mapped, and + # the Settings UI that edits this needs an admin. A stored value still wins. + roles_claim_path: str = env_str("SM_OIDC_ROLES_CLAIM_PATH", _UNSET) + + login_redirect_url: str = DEFAULT_LOGIN_REDIRECT_URL + jwks_cache_ttl_seconds: int = Field( + default=3600, ge=0, json_schema_extra={"requires_restart": True} + ) + + role_mapping: dict[str, str] = Field( + default_factory=lambda: {"admin": "admin", "user": "user"}, + ) + + @field_validator("login_redirect_url") + @classmethod + def _non_empty_redirect(cls, value: str) -> str: + return non_empty_redirect(value, default=DEFAULT_LOGIN_REDIRECT_URL) + + @model_validator(mode="after") + def _apply_preset(self) -> OidcSettings: + preset = get_preset(self.provider) + if self.provider == "entra": + from urllib.parse import urlsplit + + forbidden = {"common", "organizations", "consumers"} + segments = urlsplit(self.discovery_url).path.lower().split("/") + if self.tenant_id.strip().lower() in forbidden or forbidden.intersection(segments): + raise ValueError("Entra supports single-tenant configuration only") + for name in ( + "uid_claim", + "username_claim", + "email_claim", + "name_claim", + "roles_claim_path", + "scope", + ): + if getattr(self, name) == _UNSET: + setattr(self, name, getattr(preset, name)) + if not self.discovery_url and self.tenant_id: + self.discovery_url = preset.discovery_url(self.tenant_id) + return self + + @model_validator(mode="after") + def _check_required_in_production(self) -> OidcSettings: + env = os.environ.get("SM_ENVIRONMENT", "development") + if env in NON_PROD_ENVIRONMENTS: + return self + required = ("discovery_url", "client_id", "client_secret") + missing = [field for field in required if not getattr(self, field)] + if missing: + raise field_error( + type(self), + missing[0], + "oidc_settings_required", + f"OIDC settings required in production: {', '.join(missing)}", + value="", + related=list(required), + ) + return self diff --git a/modules/oidc/oidc/state.py b/modules/oidc/oidc/core/state.py similarity index 72% rename from modules/oidc/oidc/state.py rename to modules/oidc/oidc/core/state.py index 53089b597..6172b7cd4 100644 --- a/modules/oidc/oidc/state.py +++ b/modules/oidc/oidc/core/state.py @@ -7,10 +7,10 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from oidc.client import OIDCClient - from oidc.discovery import OidcMetadata - from oidc.jwks import JWKSCache - from oidc.settings import OidcSettings + from oidc.core.settings import OidcSettings + from oidc.identity.client import OIDCClient + from oidc.identity.discovery import OidcMetadata + from oidc.identity.jwks import JWKSCache @dataclass diff --git a/modules/oidc/oidc/identity/__init__.py b/modules/oidc/oidc/identity/__init__.py new file mode 100644 index 000000000..312fa235b --- /dev/null +++ b/modules/oidc/oidc/identity/__init__.py @@ -0,0 +1,5 @@ +"""The auth provider. + +Discovery, OIDC client, token renewal and encryption, JWKS, the user directory +and its cache persistence. +""" diff --git a/modules/oidc/oidc/client.py b/modules/oidc/oidc/identity/client.py similarity index 100% rename from modules/oidc/oidc/client.py rename to modules/oidc/oidc/identity/client.py diff --git a/modules/oidc/oidc/directory.py b/modules/oidc/oidc/identity/directory.py similarity index 100% rename from modules/oidc/oidc/directory.py rename to modules/oidc/oidc/identity/directory.py diff --git a/modules/oidc/oidc/discovery.py b/modules/oidc/oidc/identity/discovery.py similarity index 100% rename from modules/oidc/oidc/discovery.py rename to modules/oidc/oidc/identity/discovery.py diff --git a/modules/oidc/oidc/identity/jwks.py b/modules/oidc/oidc/identity/jwks.py new file mode 100644 index 000000000..6c158864b --- /dev/null +++ b/modules/oidc/oidc/identity/jwks.py @@ -0,0 +1,5 @@ +"""Provider entry point for the shared JWKS validator.""" + +from auth.oidc_common.jwks import JWKSCache + +__all__ = ["JWKSCache"] diff --git a/modules/oidc/oidc/persistence.py b/modules/oidc/oidc/identity/persistence.py similarity index 100% rename from modules/oidc/oidc/persistence.py rename to modules/oidc/oidc/identity/persistence.py diff --git a/modules/oidc/oidc/identity/provider.py b/modules/oidc/oidc/identity/provider.py new file mode 100644 index 000000000..50264eaed --- /dev/null +++ b/modules/oidc/oidc/identity/provider.py @@ -0,0 +1,172 @@ +"""OidcAuthProvider — resolves users from Oidc JWTs or session.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +from auth.contracts.schemas import UserContext +from auth.contracts.tokens import AUTH_KIND_BEARER, is_api_token, parse_bearer +from auth.oidc_common.claims import extract_nested as _extract_nested +from auth.oidc_common.tenant import trusted_tenant, without_tenant_roles +from simple_module_hosting.session import SESSION_EXPIRES_AT_KEY, session_has_expired +from starlette.requests import Request + +from oidc.core.constants import LOGIN_PATH, LOGOUT_PATH, SESSION_USER_KEY +from oidc.identity.persistence import CachePersistence +from oidc.identity.renewal import SessionRenewal + +if TYPE_CHECKING: + from oidc.core.settings import OidcSettings + from oidc.core.state import OidcState + from oidc.identity.jwks import JWKSCache + +logger = logging.getLogger(__name__) + + +class OidcAuthProvider(CachePersistence, SessionRenewal): + """Generic OIDC authentication with issuer-scoped identities.""" + + name = "oidc" + _is_auth_provider = True + + def __init__( + self, settings: OidcSettings | None = None, *, state: OidcState | None = None + ) -> None: + self._initial_settings = settings + # The module's state object: a settings save or the boot-time DB + # hydration swaps ``state.settings``, so reading through it keeps + # ``trust_tenant_claim`` / ``role_mapping`` edits live. + self._state = state + self.jwks_cache: JWKSCache | None = None + self._warned_tenant_roles: set[str] = set() + + @property + def _settings(self) -> OidcSettings | None: + live = getattr(self._state, "settings", None) + return live if live is not None else self._initial_settings + + async def resolve_user(self, request: Request) -> UserContext | None: + credential = parse_bearer(request.headers.get("authorization")) + if credential is not None: + # An ``api_tokens`` credential is not a JWT: skip the parse and + # leave it to the principal resolver that owns it. + if is_api_token(credential): + return None + return await self._resolve_bearer(request, credential) + + session = request.scope.get("session", {}) + if not self._accept_session(session): + return None + # Renew authority from the IdP at expiry, without interrupting the request. + if type(session.get(SESSION_EXPIRES_AT_KEY)) is not int or session_has_expired(session): + return await self._refresh_session(request) + ctx = UserContext.from_session_dict(session.get(SESSION_USER_KEY)) + if ctx is not None: + # The cookie froze what was decided at login; re-decide with the + # current settings, so turning trust_tenant_claim off takes effect + # on sessions that already exist. + ctx.tenant_id = self._trusted_tenant(ctx.tenant_id) + ctx.roles = self._without_tenant_roles(ctx.roles) + return ctx + + def get_login_url(self, request: Request | None, next_url: str | None = None) -> str: + return LOGIN_PATH + + def get_logout_url(self, request: Request | None) -> str: + return LOGOUT_PATH + + def is_bearer_request(self, request: Request | None) -> bool: + if request is None: + return False + return parse_bearer(request.headers.get("authorization")) is not None + + async def _resolve_bearer(self, request: Request, token: str) -> UserContext | None: + if self.jwks_cache is None or not self._settings or not self._settings.api_audience: + logger.debug("OIDC bearer authentication unavailable") + return None + claims = await self.jwks_cache.validate_jwt(token, audience=self._settings.api_audience) + if claims is None or not self._allowed_api_client(claims): + return None + + cache_id = await self._upsert_user_cache(request, claims, throttle_login=True) + if cache_id is None: + return None + ctx = self._claims_to_user_context(claims, cache_id=cache_id) + # Full rights, but a bearer credential: it may not manage API tokens + # (``auth.contracts.tokens.AUTH_KIND_BEARER``). The browser sign-in + # builds the same context from the callback and keeps ``"session"``. + ctx.auth_kind = AUTH_KIND_BEARER + return ctx + + def _allowed_api_client(self, claims: dict[str, Any]) -> bool: + """Whether the client that obtained this API token may call us. + + Fails closed: a token naming no client (no ``azp``/``appid``) is refused. + """ + s = self._settings + client = claims.get("azp") or claims.get("appid") + if not isinstance(client, str) or not client or not s: + return False + if client == s.client_id or client in s.api_allowed_client_ids: + return True + logger.info("OIDC bearer token from a client not in api_allowed_client_ids") + return False + + def _claims_to_user_context( + self, + claims: dict[str, Any], + *, + cache_id: str, + ) -> UserContext: + s = self._settings + roles_raw = _extract_nested(claims, s.roles_claim_path) if s else None + mapping = s.role_mapping if s else {} + mapped = [mapping[r] for r in (roles_raw or []) if isinstance(r, str) and r in mapping] + return UserContext( + id=cache_id, + email=self._email(claims) or "", + email_verified=bool( + (not s or s.provider != "entra") and claims.get("email_verified") is True + ), + name=self._display_name(claims), + roles=self._without_tenant_roles(mapped), + tenant_id=self._trusted_tenant(claims.get("tenant_id")), + ) + + def _email(self, claims: dict[str, Any]) -> str | None: + """The configured email claim, or ``None`` when absent or not a string.""" + value = claims.get(self._settings.email_claim if self._settings else "email") + return value if isinstance(value, str) else None + + def _display_name(self, claims: dict[str, Any]) -> str: + s = self._settings + for name in ( + s.username_claim if s else "preferred_username", + s.name_claim if s else "name", + ): + value = claims.get(name) + if isinstance(value, str) and value: + return value + return "" + + def _subject(self, claims: dict[str, Any]) -> str: + key = self._settings.uid_claim if self._settings else "sub" + subject = claims.get(key) + if not isinstance(subject, str) or not subject.strip(): + raise ValueError("Missing configured OIDC identity claim") + return subject + + def _identity(self, claims: dict[str, Any]) -> tuple[str, str]: + issuer = claims.get("iss") + if not isinstance(issuer, str) or not issuer: + raise ValueError("Missing OIDC issuer") + return issuer, self._subject(claims) + + def _trusted_tenant(self, value: Any) -> str | None: + return trusted_tenant( + value, trust_claim=bool(self._settings and self._settings.trust_tenant_claim) + ) + + def _without_tenant_roles(self, roles: list[str]) -> list[str]: + return without_tenant_roles(roles, warned=self._warned_tenant_roles, provider="Oidc") diff --git a/modules/oidc/oidc/renewal.py b/modules/oidc/oidc/identity/renewal.py similarity index 94% rename from modules/oidc/oidc/renewal.py rename to modules/oidc/oidc/identity/renewal.py index c51b82c6b..d7bd385c7 100644 --- a/modules/oidc/oidc/renewal.py +++ b/modules/oidc/oidc/identity/renewal.py @@ -3,14 +3,14 @@ from auth.oidc_common.renewal import refresh_session from auth.oidc_common.session import SessionKeys, accept_authority, authority_marker -from oidc.constants import ( +from oidc.core.constants import ( SESSION_IDENTITY_KEY, SESSION_REFRESH_RETRY_AT_KEY, SESSION_REFRESH_TOKEN_KEY, SESSION_USER_KEY, ) -from oidc.runtime import require_client -from oidc.tokens import open_refresh_token, store_refresh_token +from oidc.core.runtime import require_client +from oidc.identity.tokens import open_refresh_token, store_refresh_token _KEYS = SessionKeys( SESSION_USER_KEY, diff --git a/modules/oidc/oidc/tokens.py b/modules/oidc/oidc/identity/tokens.py similarity index 86% rename from modules/oidc/oidc/tokens.py rename to modules/oidc/oidc/identity/tokens.py index 8c6bb6bfa..664300a09 100644 --- a/modules/oidc/oidc/tokens.py +++ b/modules/oidc/oidc/identity/tokens.py @@ -2,7 +2,7 @@ from auth.oidc_common.tokens import provider_tokens -from oidc.constants import SESSION_REFRESH_TOKEN_KEY +from oidc.core.constants import SESSION_REFRESH_TOKEN_KEY _TOKENS = provider_tokens(info=b"oidc-refresh-token", session_key=SESSION_REFRESH_TOKEN_KEY) seal_refresh_token = _TOKENS.seal_refresh_token diff --git a/modules/oidc/oidc/jwks.py b/modules/oidc/oidc/jwks.py index 6c158864b..65aed7262 100644 --- a/modules/oidc/oidc/jwks.py +++ b/modules/oidc/oidc/jwks.py @@ -1,5 +1,11 @@ -"""Provider entry point for the shared JWKS validator.""" +"""Moved to :mod:`oidc.identity.jwks`; this alias goes in 0.0.46 (module layout).""" -from auth.oidc_common.jwks import JWKSCache +from __future__ import annotations -__all__ = ["JWKSCache"] +import importlib +import sys + +from simple_module_core.deprecation import deprecated_module + +deprecated_module("oidc.jwks", remove_in="0.0.46", use="oidc.identity.jwks") +sys.modules[__name__] = importlib.import_module("oidc.identity.jwks") diff --git a/modules/oidc/oidc/module.py b/modules/oidc/oidc/module.py index 17f03c365..33ce43a4e 100644 --- a/modules/oidc/oidc/module.py +++ b/modules/oidc/oidc/module.py @@ -11,7 +11,7 @@ from simple_module_core.module_settings import register_module_settings from simple_module_core.user_directory import set_user_directory -from oidc.constants import ( +from oidc.core.constants import ( _MODULE_AUTH, _MODULE_SETTINGS, AUTH_CALLBACK_PATH, @@ -36,10 +36,10 @@ class OidcModule(ModuleBase): _is_auth_provider = True def register_settings(self, app: FastAPI) -> None: - from oidc.directory import OidcDirectory - from oidc.provider import OidcAuthProvider - from oidc.settings import OidcSettings - from oidc.state import OidcState + from oidc.core.settings import OidcSettings + from oidc.core.state import OidcState + from oidc.identity.directory import OidcDirectory + from oidc.identity.provider import OidcAuthProvider register_module_settings( app, @@ -67,8 +67,8 @@ def register_menu_items(self, registry: MenuRegistry) -> None: ) def register_routes(self, api_router: APIRouter, view_router: APIRouter) -> None: - from oidc.endpoints.api import router as api - from oidc.endpoints.views import router as views + from oidc.web.endpoints.api import router as api + from oidc.web.endpoints.views import router as views api_router.include_router(api) view_router.include_router(views) @@ -79,7 +79,7 @@ def register_public_routes(self, registry) -> None: registry.add_exact(LOGOUT_PATH, methods={"POST"}) async def on_startup(self, app: FastAPI) -> None: - from oidc.runtime import initialize + from oidc.core.runtime import initialize await initialize(app) diff --git a/modules/oidc/oidc/provider.py b/modules/oidc/oidc/provider.py index e7129191a..912c4e702 100644 --- a/modules/oidc/oidc/provider.py +++ b/modules/oidc/oidc/provider.py @@ -1,172 +1,11 @@ -"""OidcAuthProvider — resolves users from Oidc JWTs or session.""" +"""Moved to :mod:`oidc.identity.provider`; this alias goes in 0.0.46 (module layout).""" from __future__ import annotations -import logging -from typing import TYPE_CHECKING, Any +import importlib +import sys -from auth.contracts.schemas import UserContext -from auth.contracts.tokens import AUTH_KIND_BEARER, is_api_token, parse_bearer -from auth.oidc_common.claims import extract_nested as _extract_nested -from auth.oidc_common.tenant import trusted_tenant, without_tenant_roles -from simple_module_hosting.session import SESSION_EXPIRES_AT_KEY, session_has_expired -from starlette.requests import Request +from simple_module_core.deprecation import deprecated_module -from oidc.constants import LOGIN_PATH, LOGOUT_PATH, SESSION_USER_KEY -from oidc.persistence import CachePersistence -from oidc.renewal import SessionRenewal - -if TYPE_CHECKING: - from oidc.jwks import JWKSCache - from oidc.settings import OidcSettings - from oidc.state import OidcState - -logger = logging.getLogger(__name__) - - -class OidcAuthProvider(CachePersistence, SessionRenewal): - """Generic OIDC authentication with issuer-scoped identities.""" - - name = "oidc" - _is_auth_provider = True - - def __init__( - self, settings: OidcSettings | None = None, *, state: OidcState | None = None - ) -> None: - self._initial_settings = settings - # The module's state object: a settings save or the boot-time DB - # hydration swaps ``state.settings``, so reading through it keeps - # ``trust_tenant_claim`` / ``role_mapping`` edits live. - self._state = state - self.jwks_cache: JWKSCache | None = None - self._warned_tenant_roles: set[str] = set() - - @property - def _settings(self) -> OidcSettings | None: - live = getattr(self._state, "settings", None) - return live if live is not None else self._initial_settings - - async def resolve_user(self, request: Request) -> UserContext | None: - credential = parse_bearer(request.headers.get("authorization")) - if credential is not None: - # An ``api_tokens`` credential is not a JWT: skip the parse and - # leave it to the principal resolver that owns it. - if is_api_token(credential): - return None - return await self._resolve_bearer(request, credential) - - session = request.scope.get("session", {}) - if not self._accept_session(session): - return None - # Renew authority from the IdP at expiry, without interrupting the request. - if type(session.get(SESSION_EXPIRES_AT_KEY)) is not int or session_has_expired(session): - return await self._refresh_session(request) - ctx = UserContext.from_session_dict(session.get(SESSION_USER_KEY)) - if ctx is not None: - # The cookie froze what was decided at login; re-decide with the - # current settings, so turning trust_tenant_claim off takes effect - # on sessions that already exist. - ctx.tenant_id = self._trusted_tenant(ctx.tenant_id) - ctx.roles = self._without_tenant_roles(ctx.roles) - return ctx - - def get_login_url(self, request: Request | None, next_url: str | None = None) -> str: - return LOGIN_PATH - - def get_logout_url(self, request: Request | None) -> str: - return LOGOUT_PATH - - def is_bearer_request(self, request: Request | None) -> bool: - if request is None: - return False - return parse_bearer(request.headers.get("authorization")) is not None - - async def _resolve_bearer(self, request: Request, token: str) -> UserContext | None: - if self.jwks_cache is None or not self._settings or not self._settings.api_audience: - logger.debug("OIDC bearer authentication unavailable") - return None - claims = await self.jwks_cache.validate_jwt(token, audience=self._settings.api_audience) - if claims is None or not self._allowed_api_client(claims): - return None - - cache_id = await self._upsert_user_cache(request, claims, throttle_login=True) - if cache_id is None: - return None - ctx = self._claims_to_user_context(claims, cache_id=cache_id) - # Full rights, but a bearer credential: it may not manage API tokens - # (``auth.contracts.tokens.AUTH_KIND_BEARER``). The browser sign-in - # builds the same context from the callback and keeps ``"session"``. - ctx.auth_kind = AUTH_KIND_BEARER - return ctx - - def _allowed_api_client(self, claims: dict[str, Any]) -> bool: - """Whether the client that obtained this API token may call us. - - Fails closed: a token naming no client (no ``azp``/``appid``) is refused. - """ - s = self._settings - client = claims.get("azp") or claims.get("appid") - if not isinstance(client, str) or not client or not s: - return False - if client == s.client_id or client in s.api_allowed_client_ids: - return True - logger.info("OIDC bearer token from a client not in api_allowed_client_ids") - return False - - def _claims_to_user_context( - self, - claims: dict[str, Any], - *, - cache_id: str, - ) -> UserContext: - s = self._settings - roles_raw = _extract_nested(claims, s.roles_claim_path) if s else None - mapping = s.role_mapping if s else {} - mapped = [mapping[r] for r in (roles_raw or []) if isinstance(r, str) and r in mapping] - return UserContext( - id=cache_id, - email=self._email(claims) or "", - email_verified=bool( - (not s or s.provider != "entra") and claims.get("email_verified") is True - ), - name=self._display_name(claims), - roles=self._without_tenant_roles(mapped), - tenant_id=self._trusted_tenant(claims.get("tenant_id")), - ) - - def _email(self, claims: dict[str, Any]) -> str | None: - """The configured email claim, or ``None`` when absent or not a string.""" - value = claims.get(self._settings.email_claim if self._settings else "email") - return value if isinstance(value, str) else None - - def _display_name(self, claims: dict[str, Any]) -> str: - s = self._settings - for name in ( - s.username_claim if s else "preferred_username", - s.name_claim if s else "name", - ): - value = claims.get(name) - if isinstance(value, str) and value: - return value - return "" - - def _subject(self, claims: dict[str, Any]) -> str: - key = self._settings.uid_claim if self._settings else "sub" - subject = claims.get(key) - if not isinstance(subject, str) or not subject.strip(): - raise ValueError("Missing configured OIDC identity claim") - return subject - - def _identity(self, claims: dict[str, Any]) -> tuple[str, str]: - issuer = claims.get("iss") - if not isinstance(issuer, str) or not issuer: - raise ValueError("Missing OIDC issuer") - return issuer, self._subject(claims) - - def _trusted_tenant(self, value: Any) -> str | None: - return trusted_tenant( - value, trust_claim=bool(self._settings and self._settings.trust_tenant_claim) - ) - - def _without_tenant_roles(self, roles: list[str]) -> list[str]: - return without_tenant_roles(roles, warned=self._warned_tenant_roles, provider="Oidc") +deprecated_module("oidc.provider", remove_in="0.0.46", use="oidc.identity.provider") +sys.modules[__name__] = importlib.import_module("oidc.identity.provider") diff --git a/modules/oidc/oidc/settings.py b/modules/oidc/oidc/settings.py index e736113bd..299c7858c 100644 --- a/modules/oidc/oidc/settings.py +++ b/modules/oidc/oidc/settings.py @@ -1,131 +1,11 @@ -"""OIDC module settings -- DB-backed via ``register_module_settings``. - -Configuration is preset-driven: pick a ``provider`` preset (e.g. ``entra``) and -supply the few required secrets; the preset fills claim defaults and derives the -discovery URL. ``generic`` works with any OIDC provider given a ``discovery_url``. -""" +"""Moved to :mod:`oidc.core.settings`; this alias goes in 0.0.46 (module layout).""" from __future__ import annotations -import os - -from pydantic import Field, field_validator, model_validator -from pydantic_settings import SettingsConfigDict -from simple_module_core.dotenv import env_str -from simple_module_core.environments import NON_PROD_ENVIRONMENTS -from simple_module_core.redirect_safety import non_empty_redirect -from simple_module_core.settings_base import DbBackedSettings -from simple_module_core.stored_settings import field_error - -from oidc.constants import DEFAULT_LOGIN_REDIRECT_URL -from oidc.presets import get_preset - -# Sentinel for "unset by the user" so the preset can supply a default while still -# letting an explicit empty/other value win. -_UNSET = "__unset__" - - -class OidcSettings(DbBackedSettings): - """Generic OIDC configuration with per-provider presets.""" - - model_config = SettingsConfigDict(extra="ignore") - - provider: str = Field( - default=env_str("SM_OIDC_PROVIDER", "generic"), json_schema_extra={"requires_restart": True} - ) - - # Endpoint discovery. ``discovery_url`` wins; otherwise the preset derives it - # from ``tenant_id`` (Entra). The well-known doc supplies all endpoints + issuer. - discovery_url: str = Field( - default=env_str("SM_OIDC_DISCOVERY_URL", ""), json_schema_extra={"requires_restart": True} - ) - tenant_id: str = Field( - default=env_str("SM_OIDC_TENANT_ID", ""), json_schema_extra={"requires_restart": True} - ) - - client_id: str = Field( - default=env_str("SM_OIDC_CLIENT_ID", ""), json_schema_extra={"requires_restart": True} - ) - client_secret: str = Field( - default=env_str("SM_OIDC_CLIENT_SECRET", ""), json_schema_extra={"requires_restart": True} - ) - - # Bearer authentication is opt-in; browser ID tokens always use client_id. - api_audience: str = "" - # Clients (``azp``, or ``appid`` on Entra v1 tokens) allowed to call the API - # with a bearer token, besides ``client_id``. An API token is minted for our - # audience but by any client the IdP lets ask for it, so name each one. - api_allowed_client_ids: list[str] = Field( - default_factory=list, - description=( - "Client IDs (token azp, or appid on Entra v1) besides client_id allowed to call " - "the API with a bearer token. Empty: only this app's own client." - ), - ) - trust_tenant_claim: bool = False - - # Claim mapping. ``_UNSET`` defaults are replaced by the preset's value. - scope: str = _UNSET - uid_claim: str = _UNSET - username_claim: str = _UNSET - email_claim: str = _UNSET - name_claim: str = _UNSET - # Env-seedable: the first admin can only sign in once roles are mapped, and - # the Settings UI that edits this needs an admin. A stored value still wins. - roles_claim_path: str = env_str("SM_OIDC_ROLES_CLAIM_PATH", _UNSET) - - login_redirect_url: str = DEFAULT_LOGIN_REDIRECT_URL - jwks_cache_ttl_seconds: int = Field( - default=3600, ge=0, json_schema_extra={"requires_restart": True} - ) - - role_mapping: dict[str, str] = Field( - default_factory=lambda: {"admin": "admin", "user": "user"}, - ) - - @field_validator("login_redirect_url") - @classmethod - def _non_empty_redirect(cls, value: str) -> str: - return non_empty_redirect(value, default=DEFAULT_LOGIN_REDIRECT_URL) - - @model_validator(mode="after") - def _apply_preset(self) -> OidcSettings: - preset = get_preset(self.provider) - if self.provider == "entra": - from urllib.parse import urlsplit +import importlib +import sys - forbidden = {"common", "organizations", "consumers"} - segments = urlsplit(self.discovery_url).path.lower().split("/") - if self.tenant_id.strip().lower() in forbidden or forbidden.intersection(segments): - raise ValueError("Entra supports single-tenant configuration only") - for name in ( - "uid_claim", - "username_claim", - "email_claim", - "name_claim", - "roles_claim_path", - "scope", - ): - if getattr(self, name) == _UNSET: - setattr(self, name, getattr(preset, name)) - if not self.discovery_url and self.tenant_id: - self.discovery_url = preset.discovery_url(self.tenant_id) - return self +from simple_module_core.deprecation import deprecated_module - @model_validator(mode="after") - def _check_required_in_production(self) -> OidcSettings: - env = os.environ.get("SM_ENVIRONMENT", "development") - if env in NON_PROD_ENVIRONMENTS: - return self - required = ("discovery_url", "client_id", "client_secret") - missing = [field for field in required if not getattr(self, field)] - if missing: - raise field_error( - type(self), - missing[0], - "oidc_settings_required", - f"OIDC settings required in production: {', '.join(missing)}", - value="", - related=list(required), - ) - return self +deprecated_module("oidc.settings", remove_in="0.0.46", use="oidc.core.settings") +sys.modules[__name__] = importlib.import_module("oidc.core.settings") diff --git a/modules/oidc/oidc/web/__init__.py b/modules/oidc/oidc/web/__init__.py new file mode 100644 index 000000000..acd75a07e --- /dev/null +++ b/modules/oidc/oidc/web/__init__.py @@ -0,0 +1 @@ +"""The HTTP side: the REST and Inertia endpoints.""" diff --git a/modules/oidc/oidc/endpoints/__init__.py b/modules/oidc/oidc/web/endpoints/__init__.py similarity index 100% rename from modules/oidc/oidc/endpoints/__init__.py rename to modules/oidc/oidc/web/endpoints/__init__.py diff --git a/modules/oidc/oidc/endpoints/api.py b/modules/oidc/oidc/web/endpoints/api.py similarity index 97% rename from modules/oidc/oidc/endpoints/api.py rename to modules/oidc/oidc/web/endpoints/api.py index 8402c10b7..0cf60d009 100644 --- a/modules/oidc/oidc/endpoints/api.py +++ b/modules/oidc/oidc/web/endpoints/api.py @@ -10,15 +10,15 @@ from fastapi import APIRouter, HTTPException, Request from starlette.responses import RedirectResponse -from oidc.constants import ( +from oidc.core.constants import ( SESSION_IDENTITY_KEY, SESSION_NONCE_KEY, SESSION_STATE_KEY, SESSION_USER_KEY, SESSION_VERIFIER_KEY, ) -from oidc.runtime import require_client -from oidc.tokens import store_refresh_token +from oidc.core.runtime import require_client +from oidc.identity.tokens import store_refresh_token logger = logging.getLogger(__name__) router = APIRouter(prefix="/auth", tags=["oidc-auth"]) diff --git a/modules/oidc/oidc/endpoints/views.py b/modules/oidc/oidc/web/endpoints/views.py similarity index 88% rename from modules/oidc/oidc/endpoints/views.py rename to modules/oidc/oidc/web/endpoints/views.py index 52d93fa2a..6b8eafeb7 100644 --- a/modules/oidc/oidc/endpoints/views.py +++ b/modules/oidc/oidc/web/endpoints/views.py @@ -8,10 +8,15 @@ from simple_module_inertia.response import location from starlette.responses import RedirectResponse -from oidc.constants import _PAGE_LOGGED_OUT, _PAGE_LOGIN, LOGGED_OUT_PATH, SESSION_REFRESH_TOKEN_KEY -from oidc.renewal import _KEYS -from oidc.runtime import is_signed_in -from oidc.tokens import open_refresh_token +from oidc.core.constants import ( + _PAGE_LOGGED_OUT, + _PAGE_LOGIN, + LOGGED_OUT_PATH, + SESSION_REFRESH_TOKEN_KEY, +) +from oidc.core.runtime import is_signed_in +from oidc.identity.renewal import _KEYS +from oidc.identity.tokens import open_refresh_token logger = logging.getLogger(__name__) router = APIRouter(tags=["oidc-views"]) diff --git a/modules/oidc/tests/conftest.py b/modules/oidc/tests/conftest.py index 9dc9408ab..e33e7060d 100644 --- a/modules/oidc/tests/conftest.py +++ b/modules/oidc/tests/conftest.py @@ -6,7 +6,7 @@ import pytest from cryptography.hazmat.primitives.asymmetric import rsa from jwt.algorithms import RSAAlgorithm -from oidc.settings import OidcSettings +from oidc.core.settings import OidcSettings from oidc_flow_helpers import _AUDIENCE, _KID, _PRIVATE_KEY from simple_module_core.discovery import discover_modules from simple_module_db.base import all_module_bases diff --git a/modules/oidc/tests/test_oidc_boot.py b/modules/oidc/tests/core/module/test_oidc_boot.py similarity index 92% rename from modules/oidc/tests/test_oidc_boot.py rename to modules/oidc/tests/core/module/test_oidc_boot.py index 3e568a7b7..eb1477eeb 100644 --- a/modules/oidc/tests/test_oidc_boot.py +++ b/modules/oidc/tests/core/module/test_oidc_boot.py @@ -5,11 +5,11 @@ import httpx import pytest +from oidc.core.runtime import require_client +from oidc.core.settings import OidcSettings +from oidc.core.state import OidcState +from oidc.identity.provider import OidcAuthProvider from oidc.module import OidcModule -from oidc.provider import OidcAuthProvider -from oidc.runtime import require_client -from oidc.settings import OidcSettings -from oidc.state import OidcState from starlette.requests import Request diff --git a/modules/oidc/tests/test_oidc_migration.py b/modules/oidc/tests/core/module/test_oidc_migration.py similarity index 100% rename from modules/oidc/tests/test_oidc_migration.py rename to modules/oidc/tests/core/module/test_oidc_migration.py diff --git a/modules/oidc/tests/test_oidc_module.py b/modules/oidc/tests/core/module/test_oidc_module.py similarity index 94% rename from modules/oidc/tests/test_oidc_module.py rename to modules/oidc/tests/core/module/test_oidc_module.py index c27d1baa7..d2f5a46d7 100644 --- a/modules/oidc/tests/test_oidc_module.py +++ b/modules/oidc/tests/core/module/test_oidc_module.py @@ -16,7 +16,7 @@ def test_oidc_module_meta(): def test_oidc_provider_satisfies_protocol(): - from oidc.provider import OidcAuthProvider + from oidc.identity.provider import OidcAuthProvider provider = OidcAuthProvider() assert isinstance(provider, AuthProvider) diff --git a/modules/oidc/tests/test_oidc_settings_runtime.py b/modules/oidc/tests/core/runtime/test_oidc_settings_runtime.py similarity index 97% rename from modules/oidc/tests/test_oidc_settings_runtime.py rename to modules/oidc/tests/core/runtime/test_oidc_settings_runtime.py index 13568c783..2657652c0 100644 --- a/modules/oidc/tests/test_oidc_settings_runtime.py +++ b/modules/oidc/tests/core/runtime/test_oidc_settings_runtime.py @@ -1,6 +1,6 @@ """OIDC policy uses hydrated settings even without the Settings UI (#192).""" -from oidc.settings import OidcSettings +from oidc.core.settings import OidcSettings from settings.invalidation import reload_package from settings.service import SettingService from settings.store import SettingsStore diff --git a/modules/oidc/tests/test_oidc_settings_presets.py b/modules/oidc/tests/core/settings/test_oidc_settings_presets.py similarity index 96% rename from modules/oidc/tests/test_oidc_settings_presets.py rename to modules/oidc/tests/core/settings/test_oidc_settings_presets.py index 2314059c2..db70da832 100644 --- a/modules/oidc/tests/test_oidc_settings_presets.py +++ b/modules/oidc/tests/core/settings/test_oidc_settings_presets.py @@ -3,7 +3,7 @@ from __future__ import annotations import pytest -from oidc.settings import OidcSettings +from oidc.core.settings import OidcSettings from pydantic import ValidationError @@ -111,7 +111,7 @@ def test_roles_claim_path_is_seeded_from_env_for_the_first_admin(): import subprocess import sys - code = "from oidc.settings import OidcSettings; print(OidcSettings().roles_claim_path)" + code = "from oidc.core.settings import OidcSettings; print(OidcSettings().roles_claim_path)" env = {**os.environ, "SM_OIDC_ROLES_CLAIM_PATH": "realm_access.roles"} out = subprocess.run( [sys.executable, "-c", code], env=env, capture_output=True, text=True, check=True diff --git a/modules/oidc/tests/test_oidc_client.py b/modules/oidc/tests/identity/client/test_oidc_client.py similarity index 98% rename from modules/oidc/tests/test_oidc_client.py rename to modules/oidc/tests/identity/client/test_oidc_client.py index 0e3efa969..ce72491f4 100644 --- a/modules/oidc/tests/test_oidc_client.py +++ b/modules/oidc/tests/identity/client/test_oidc_client.py @@ -3,7 +3,7 @@ from __future__ import annotations import pytest -from oidc.client import OIDCClient +from oidc.identity.client import OIDCClient @pytest.fixture diff --git a/modules/oidc/tests/test_oidc_directory.py b/modules/oidc/tests/identity/directory/test_oidc_directory.py similarity index 100% rename from modules/oidc/tests/test_oidc_directory.py rename to modules/oidc/tests/identity/directory/test_oidc_directory.py diff --git a/modules/oidc/tests/test_oidc_discovery.py b/modules/oidc/tests/identity/discovery/test_oidc_discovery.py similarity index 98% rename from modules/oidc/tests/test_oidc_discovery.py rename to modules/oidc/tests/identity/discovery/test_oidc_discovery.py index 8c672f256..b57ad8334 100644 --- a/modules/oidc/tests/test_oidc_discovery.py +++ b/modules/oidc/tests/identity/discovery/test_oidc_discovery.py @@ -3,7 +3,7 @@ from __future__ import annotations import pytest -from oidc.discovery import OidcMetadata, fetch_metadata +from oidc.identity.discovery import OidcMetadata, fetch_metadata _ENTRA_DOC = { "issuer": "https://login.microsoftonline.com/tid/v2.0", diff --git a/modules/oidc/tests/test_oidc_jwks.py b/modules/oidc/tests/identity/jwks/test_oidc_jwks.py similarity index 98% rename from modules/oidc/tests/test_oidc_jwks.py rename to modules/oidc/tests/identity/jwks/test_oidc_jwks.py index a43d0160d..0a18238fa 100644 --- a/modules/oidc/tests/test_oidc_jwks.py +++ b/modules/oidc/tests/identity/jwks/test_oidc_jwks.py @@ -5,7 +5,7 @@ import time import pytest -from oidc.jwks import JWKSCache +from oidc.identity.jwks import JWKSCache from oidc_flow_helpers import make_jwks_response, sign_token diff --git a/modules/oidc/tests/test_oidc_jwks_resilience.py b/modules/oidc/tests/identity/jwks/test_oidc_jwks_resilience.py similarity index 99% rename from modules/oidc/tests/test_oidc_jwks_resilience.py rename to modules/oidc/tests/identity/jwks/test_oidc_jwks_resilience.py index 7408fd5b8..e7d63ec2d 100644 --- a/modules/oidc/tests/test_oidc_jwks_resilience.py +++ b/modules/oidc/tests/identity/jwks/test_oidc_jwks_resilience.py @@ -6,7 +6,7 @@ import httpx import pytest -from oidc.jwks import JWKSCache +from oidc.identity.jwks import JWKSCache from oidc_flow_helpers import make_jwks_response, sign_token diff --git a/modules/oidc/tests/test_oidc_cache_persistence.py b/modules/oidc/tests/identity/persistence/test_oidc_cache_persistence.py similarity index 97% rename from modules/oidc/tests/test_oidc_cache_persistence.py rename to modules/oidc/tests/identity/persistence/test_oidc_cache_persistence.py index 84518fb29..30453bc94 100644 --- a/modules/oidc/tests/test_oidc_cache_persistence.py +++ b/modules/oidc/tests/identity/persistence/test_oidc_cache_persistence.py @@ -4,9 +4,9 @@ from types import SimpleNamespace import pytest +from oidc.core.settings import OidcSettings +from oidc.identity.provider import OidcAuthProvider from oidc.models import OidcUserCache -from oidc.provider import OidcAuthProvider -from oidc.settings import OidcSettings from simple_module_db import independent_session from sqlalchemy import event, select from starlette.requests import Request @@ -145,7 +145,7 @@ async def test_failed_cache_never_falls_back_to_external_subject(cache_request, def broken(*args): raise RuntimeError("database unavailable") - monkeypatch.setattr("oidc.persistence.independent_session", broken) + monkeypatch.setattr("oidc.identity.persistence.independent_session", broken) assert await OidcAuthProvider()._upsert_user_cache(cache_request, CLAIMS) is None diff --git a/modules/oidc/tests/test_oidc_bearer_policy.py b/modules/oidc/tests/identity/provider/test_oidc_bearer_policy.py similarity index 100% rename from modules/oidc/tests/test_oidc_bearer_policy.py rename to modules/oidc/tests/identity/provider/test_oidc_bearer_policy.py diff --git a/modules/oidc/tests/test_oidc_jwt_policy.py b/modules/oidc/tests/identity/provider/test_oidc_jwt_policy.py similarity index 98% rename from modules/oidc/tests/test_oidc_jwt_policy.py rename to modules/oidc/tests/identity/provider/test_oidc_jwt_policy.py index 45ba9d1c1..c031564ae 100644 --- a/modules/oidc/tests/test_oidc_jwt_policy.py +++ b/modules/oidc/tests/identity/provider/test_oidc_jwt_policy.py @@ -5,7 +5,7 @@ import jwt import pytest from cryptography.hazmat.primitives.asymmetric import rsa -from oidc.jwks import JWKSCache +from oidc.identity.jwks import JWKSCache @pytest.fixture diff --git a/modules/oidc/tests/test_oidc_legacy_authority.py b/modules/oidc/tests/identity/provider/test_oidc_legacy_authority.py similarity index 96% rename from modules/oidc/tests/test_oidc_legacy_authority.py rename to modules/oidc/tests/identity/provider/test_oidc_legacy_authority.py index 7b214fb70..41f440b47 100644 --- a/modules/oidc/tests/test_oidc_legacy_authority.py +++ b/modules/oidc/tests/identity/provider/test_oidc_legacy_authority.py @@ -4,9 +4,9 @@ import pytest from auth.contracts.schemas import UserContext -from oidc.jwks import JWKSCache -from oidc.provider import OidcAuthProvider -from oidc.settings import OidcSettings +from oidc.core.settings import OidcSettings +from oidc.identity.jwks import JWKSCache +from oidc.identity.provider import OidcAuthProvider from starlette.requests import Request diff --git a/modules/oidc/tests/test_oidc_provider.py b/modules/oidc/tests/identity/provider/test_oidc_provider.py similarity index 97% rename from modules/oidc/tests/test_oidc_provider.py rename to modules/oidc/tests/identity/provider/test_oidc_provider.py index 6af110c5e..64e9c2800 100644 --- a/modules/oidc/tests/test_oidc_provider.py +++ b/modules/oidc/tests/identity/provider/test_oidc_provider.py @@ -7,8 +7,8 @@ import pytest from auth.contracts.provider import AuthProvider from auth.contracts.schemas import UserContext -from oidc.provider import OidcAuthProvider -from oidc.settings import OidcSettings +from oidc.core.settings import OidcSettings +from oidc.identity.provider import OidcAuthProvider @pytest.fixture diff --git a/modules/oidc/tests/test_oidc_tenant_resolver.py b/modules/oidc/tests/identity/provider/test_oidc_tenant_resolver.py similarity index 94% rename from modules/oidc/tests/test_oidc_tenant_resolver.py rename to modules/oidc/tests/identity/provider/test_oidc_tenant_resolver.py index f84d83a93..f2004d799 100644 --- a/modules/oidc/tests/test_oidc_tenant_resolver.py +++ b/modules/oidc/tests/identity/provider/test_oidc_tenant_resolver.py @@ -6,8 +6,8 @@ from __future__ import annotations -from oidc.provider import OidcAuthProvider -from oidc.settings import OidcSettings +from oidc.core.settings import OidcSettings +from oidc.identity.provider import OidcAuthProvider from starlette.requests import Request _USER_ID = "11111111-1111-1111-1111-111111111111" diff --git a/modules/oidc/tests/test_oidc_refresh.py b/modules/oidc/tests/identity/renewal/test_oidc_refresh.py similarity index 97% rename from modules/oidc/tests/test_oidc_refresh.py rename to modules/oidc/tests/identity/renewal/test_oidc_refresh.py index 6905bb0bd..5c2d385ad 100644 --- a/modules/oidc/tests/test_oidc_refresh.py +++ b/modules/oidc/tests/identity/renewal/test_oidc_refresh.py @@ -7,10 +7,10 @@ import httpx import pytest from auth.contracts.schemas import UserContext -from oidc.client import OIDCClient -from oidc.provider import OidcAuthProvider -from oidc.settings import OidcSettings -from oidc.tokens import open_refresh_token, seal_refresh_token +from oidc.core.settings import OidcSettings +from oidc.identity.client import OIDCClient +from oidc.identity.provider import OidcAuthProvider +from oidc.identity.tokens import open_refresh_token, seal_refresh_token from starlette.requests import Request diff --git a/modules/oidc/tests/test_oidc_session_transaction.py b/modules/oidc/tests/identity/renewal/test_oidc_session_transaction.py similarity index 97% rename from modules/oidc/tests/test_oidc_session_transaction.py rename to modules/oidc/tests/identity/renewal/test_oidc_session_transaction.py index a5efc271e..43bf13a98 100644 --- a/modules/oidc/tests/test_oidc_session_transaction.py +++ b/modules/oidc/tests/identity/renewal/test_oidc_session_transaction.py @@ -8,10 +8,10 @@ import httpx import pytest from fastapi import HTTPException -from oidc.endpoints import api -from oidc.provider import OidcAuthProvider -from oidc.settings import OidcSettings -from oidc.tokens import open_refresh_token +from oidc.core.settings import OidcSettings +from oidc.identity.provider import OidcAuthProvider +from oidc.identity.tokens import open_refresh_token +from oidc.web.endpoints import api from simple_module_hosting.csrf import get_csrf_token from simple_module_hosting.session import SessionMiddleware, preserve_across_sign_in from starlette.requests import Request diff --git a/modules/oidc/tests/test_oidc_authorization.py b/modules/oidc/tests/web/endpoints/test_oidc_authorization.py similarity index 96% rename from modules/oidc/tests/test_oidc_authorization.py rename to modules/oidc/tests/web/endpoints/test_oidc_authorization.py index 81b98afc4..dd0d56132 100644 --- a/modules/oidc/tests/test_oidc_authorization.py +++ b/modules/oidc/tests/web/endpoints/test_oidc_authorization.py @@ -5,9 +5,9 @@ import pytest from auth.contracts.schemas import UserContext -from oidc.provider import OidcAuthProvider -from oidc.settings import OidcSettings -from oidc.state import OidcState +from oidc.core.settings import OidcSettings +from oidc.core.state import OidcState +from oidc.identity.provider import OidcAuthProvider from starlette.requests import Request diff --git a/modules/oidc/tests/test_oidc_authorization_query.py b/modules/oidc/tests/web/endpoints/test_oidc_authorization_query.py similarity index 93% rename from modules/oidc/tests/test_oidc_authorization_query.py rename to modules/oidc/tests/web/endpoints/test_oidc_authorization_query.py index 1c6341964..b8a90d9eb 100644 --- a/modules/oidc/tests/test_oidc_authorization_query.py +++ b/modules/oidc/tests/web/endpoints/test_oidc_authorization_query.py @@ -3,8 +3,8 @@ from urllib.parse import parse_qs, urlsplit import pytest -from oidc.client import OIDCClient -from oidc.discovery import OidcMetadata +from oidc.identity.client import OIDCClient +from oidc.identity.discovery import OidcMetadata @pytest.mark.parametrize("query", ["", "?tenant=alpha", "?tenant=alpha&policy=a%2Bb"]) diff --git a/modules/oidc/tests/test_oidc_endpoint_auth.py b/modules/oidc/tests/web/endpoints/test_oidc_endpoint_auth.py similarity index 94% rename from modules/oidc/tests/test_oidc_endpoint_auth.py rename to modules/oidc/tests/web/endpoints/test_oidc_endpoint_auth.py index 46383f82f..e17e3b396 100644 --- a/modules/oidc/tests/test_oidc_endpoint_auth.py +++ b/modules/oidc/tests/web/endpoints/test_oidc_endpoint_auth.py @@ -6,11 +6,11 @@ import httpx import pytest -from oidc.constants import SESSION_REFRESH_TOKEN_KEY -from oidc.runtime import initialize -from oidc.settings import OidcSettings -from oidc.state import OidcState -from oidc.tokens import seal_refresh_token +from oidc.core.constants import SESSION_REFRESH_TOKEN_KEY +from oidc.core.runtime import initialize +from oidc.core.settings import OidcSettings +from oidc.core.state import OidcState +from oidc.identity.tokens import seal_refresh_token from starlette.requests import Request _DOC = { @@ -131,7 +131,7 @@ def _logout_request(app, session): async def test_logout_revokes_before_clearing_even_on_failure( endpoint_app, basic, failure, httpx_mock, caplog ): - from oidc.endpoints.views import logout + from oidc.web.endpoints.views import logout session = {SESSION_REFRESH_TOKEN_KEY: seal_refresh_token(endpoint_app, "private-refresh")} calls = [] @@ -167,7 +167,7 @@ def revoke(sent): indirect=["endpoint_app"], ) async def test_logout_skips_unavailable_revocation(endpoint_app, token, httpx_mock): - from oidc.endpoints.views import logout + from oidc.web.endpoints.views import logout session = {} if token: @@ -203,7 +203,7 @@ async def test_revocation_uses_its_own_advertised_auth_method(endpoint_app, http async def test_revocation_total_time_is_capped(endpoint_app, monkeypatch): import asyncio - from oidc import client as client_module + from oidc.identity import client as client_module async def hang(*args, **kwargs): await asyncio.Event().wait() # an IdP that never answers diff --git a/modules/oidc/tests/test_oidc_flow.py b/modules/oidc/tests/web/endpoints/test_oidc_flow.py similarity index 100% rename from modules/oidc/tests/test_oidc_flow.py rename to modules/oidc/tests/web/endpoints/test_oidc_flow.py diff --git a/modules/oidc/tests/test_oidc_keycloak_flow.py b/modules/oidc/tests/web/endpoints/test_oidc_keycloak_flow.py similarity index 100% rename from modules/oidc/tests/test_oidc_keycloak_flow.py rename to modules/oidc/tests/web/endpoints/test_oidc_keycloak_flow.py diff --git a/modules/oidc/tests/test_oidc_logout.py b/modules/oidc/tests/web/endpoints/test_oidc_logout.py similarity index 98% rename from modules/oidc/tests/test_oidc_logout.py rename to modules/oidc/tests/web/endpoints/test_oidc_logout.py index 5aed4e85a..21ff06307 100644 --- a/modules/oidc/tests/test_oidc_logout.py +++ b/modules/oidc/tests/web/endpoints/test_oidc_logout.py @@ -73,7 +73,7 @@ async def test_logout_drops_the_sessions_shared_renewal_outcome(oidc_app, flow_c import time from auth.models import RefreshOutcome - from oidc.renewal import _KEYS + from oidc.identity.renewal import _KEYS from simple_module_db import independent_session from sqlmodel import select diff --git a/modules/users/tests/test_users_login_deep_link.py b/modules/users/tests/test_users_login_deep_link.py index a95e066f7..2cfc6abae 100644 --- a/modules/users/tests/test_users_login_deep_link.py +++ b/modules/users/tests/test_users_login_deep_link.py @@ -121,7 +121,7 @@ def test_redirecting_providers_sanitise_the_target(self, relative: str) -> None: @pytest.mark.parametrize( "relative", - ["modules/keycloak/keycloak/web/endpoints/api.py", "modules/oidc/oidc/endpoints/api.py"], + ["modules/keycloak/keycloak/web/endpoints/api.py", "modules/oidc/oidc/web/endpoints/api.py"], ) def test_oidc_providers_complete_login_through_the_shared_sign_in(relative: str) -> None: # The shared helper honours SESSION_NEXT_KEY; a provider that stopped