diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b1a4b8b1..0712aca51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,12 +16,13 @@ All notable changes to this project are documented in this file. The format is b ### Changed -- Scaffold new modules with `core/` and `web/` areas and mirrored nested Python tests; existing modules migrate individually. See `docs/module-layout.md`. +- Scaffold new modules with `core/` and `web/` areas and mirrored nested Python tests; existing modules migrate individually. See `docs/module-layout.md`. Moved modules log under their new paths (for example `mail.delivery.sender`), so logging config that names an old logger needs the new name. - `mail` follows the module layout: `mail.core.{constants,settings,state,data_compatibility}` and `mail.delivery.{sender,runtime}`. `mail.settings` and `mail.constants` still import, with a `DeprecationWarning`, until 0.0.46. - `site_lock` follows the module layout: `site_lock.core.{constants,settings,state}` and `site_lock.web.{middleware,page,rate_limit}` (the unlock template moves with the page). `site_lock.settings` still imports, with a `DeprecationWarning`, until 0.0.46. - `sharing` follows the module layout: `sharing.core.{constants,settings,services,service,policy,audience,tenant_cleanup,user_cleanup}`, `sharing.web.{deps,present,endpoints}`, and the page components and types move to `ui/`. `sharing.policy`, `sharing.constants` and `sharing.endpoints.api` still import, with a `DeprecationWarning`, until 0.0.46; new code imports `sharing.core.policy`. - `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. ### Fixed diff --git a/docs/framework/middleware.md b/docs/framework/middleware.md index 4fe9f4c76..7c286a579 100644 --- a/docs/framework/middleware.md +++ b/docs/framework/middleware.md @@ -96,7 +96,7 @@ Behind a TLS-terminating proxy this is **recommended**: without it the app belie It used to be load-bearing for the UI too, because Inertia's page url was absolute and its cross-scheme `pushState` threw a `SecurityError` on every page (GH #223). The page url is root-relative now, so that failure mode is gone whether or not this is set. -It is still load-bearing for anything else that reads `request.url.scheme` or calls `request.url_for(...)` to build an absolute url — OAuth/OIDC's `callback_url` (`modules/users/users/oauth/api.py`, `modules/keycloak/keycloak/endpoints/api.py`) and the locale cookie's `secure` flag (`simple_module_hosting/i18n_routes.py`) among them. Left unset behind a TLS-terminating proxy, an OAuth redirect_uri is built as `http://…` and most providers reject it as a mismatch against the `https://…` one registered in their console. +It is still load-bearing for anything else that reads `request.url.scheme` or calls `request.url_for(...)` to build an absolute url — OAuth/OIDC's `callback_url` (`modules/users/users/oauth/api.py`, `modules/keycloak/keycloak/web/endpoints/api.py`) and the locale cookie's `secure` flag (`simple_module_hosting/i18n_routes.py`) among them. Left unset behind a TLS-terminating proxy, an OAuth redirect_uri is built as `http://…` and most providers reject it as a mismatch against the `https://…` one registered in their console. Set it to a comma-separated list of proxy IPs/CIDRs, or `*` to trust any peer — correct when the container is only reachable through one proxy, wrong when anything else can connect. diff --git a/docs/framework/user-directory.md b/docs/framework/user-directory.md index 488a6f57e..d382280a3 100644 --- a/docs/framework/user-directory.md +++ b/docs/framework/user-directory.md @@ -47,7 +47,7 @@ The active auth provider installs its directory in `register_settings` with | Provider | Directory | Knows | |---|---|---| | `users` | `users.directory.UsersDirectory` | every local account, roles via `users_user_role`, all three stats | -| `keycloak` | `keycloak.directory.KeycloakDirectory` | users who have signed in at least once (`keycloak_user_cache`); roles `None`; `created_this_month` `None`; `entity_type` `None` | +| `keycloak` | `keycloak.identity.directory.KeycloakDirectory` | users who have signed in at least once (`keycloak_user_cache`); roles `None`; `created_this_month` `None`; `entity_type` `None` | | none | `NullUserDirectory` | nobody; every stat `None`; `match_ids` `None` | A new provider implements the four methods and `entity_type`, and registers diff --git a/docs/module-layout.md b/docs/module-layout.md index 7687039e8..5988477a3 100644 --- a/docs/module-layout.md +++ b/docs/module-layout.md @@ -213,7 +213,7 @@ when a later module PR moves files. | dashboard | 2 | 0 | follows this layout | | feature_flags | 3 (+1 shim) | 0 | follows this layout | | file_storage | 39 | 65 | flat | -| keycloak | 13 | 19 | flat | +| keycloak | 3 (+5 shims) | 0 | follows this layout | | mail | 2 (+2 shims) | 0 | follows this layout | | oidc | 16 | 24 | flat | | permissions | 12 | 14 | flat | diff --git a/docs/modules/keycloak.md b/docs/modules/keycloak.md index 6d3585b4a..af9cb375b 100644 --- a/docs/modules/keycloak.md +++ b/docs/modules/keycloak.md @@ -53,7 +53,7 @@ The browser session uses a signed, **unencrypted** cookie, so the refresh token ## Bearer-token validation -`JWKSCache` (`keycloak/jwks.py`) caches Keycloak's RS256 public signing keys (`/realms//protocol/openid-connect/certs`) and validates tokens with PyJWT, requiring `iss`, non-empty `sub`, `aud`, integer `exp` and integer `iat`. +`JWKSCache` (`keycloak/identity/jwks.py`) caches Keycloak's RS256 public signing keys (`/realms//protocol/openid-connect/certs`) and validates tokens with PyJWT, requiring `iss`, non-empty `sub`, `aud`, integer `exp` and integer `iat`. The issuer must match and `client_id` must be among the token's `aud` values (extra audiences and the `azp` are not checked, see below). A 10-second clock leeway tolerates small clock skew, while browser authority still ends at the access token's exact expiry. @@ -64,7 +64,7 @@ On a valid bearer token the provider upserts a `KeycloakUserCache` row keyed by ## Browser login flow (OIDC) -`OIDCClient` (`keycloak/oidc.py`) wraps the realm's authorization-code endpoints. +`OIDCClient` (`keycloak/identity/oidc.py`) wraps the realm's authorization-code endpoints. | Method + path | What | |---|---| @@ -141,7 +141,7 @@ Keycloak registers no setup steps: its local `users` table is legitimately empty | `last_login_at` | `datetime \| None` | written on login; bearer activity writes throttled to once per five minutes | The cache backs this module's [user directory](/framework/user-directory) -(`keycloak.directory.KeycloakDirectory`, installed in `register_settings`), so +(`keycloak.identity.directory.KeycloakDirectory`, installed in `register_settings`), so the audit log, dashboard, file storage and the permissions grants screen name and count Keycloak users. It knows only users who have signed in at least once; roles are `None` (they live in the IdP) and `created_this_month` is `None`. diff --git a/host/tests/test_inactive_provider_public_paths.py b/host/tests/test_inactive_provider_public_paths.py index 9e5b542e6..20ac54366 100644 --- a/host/tests/test_inactive_provider_public_paths.py +++ b/host/tests/test_inactive_provider_public_paths.py @@ -9,7 +9,7 @@ from __future__ import annotations -from keycloak.constants import PUBLIC_PATH_PREFIXES as KEYCLOAK_PREFIXES +from keycloak.core.constants import PUBLIC_PATH_PREFIXES as KEYCLOAK_PREFIXES from keycloak.module import KeycloakModule from simple_module_core.discovery import select_auth_provider from simple_module_core.public_routes import PublicRouteRegistry diff --git a/host/tests/test_module_layout.py b/host/tests/test_module_layout.py index 2e78fa4ad..83ffe85e1 100644 --- a/host/tests/test_module_layout.py +++ b/host/tests/test_module_layout.py @@ -32,6 +32,7 @@ ) # Add named domain areas here only when they contain several related features. DOMAIN_AREAS: dict[str, frozenset[str]] = { + "keycloak": frozenset({"identity"}), "mail": frozenset({"delivery"}), } @@ -84,12 +85,6 @@ uploads.py visibility.py worker.py worker_support.py """.split() ), - "keycloak": frozenset( - """ - constants.py directory.py endpoints jwks.py oidc.py persistence.py provider.py - renewal.py settings.py state.py tokens.py - """.split() - ), "oidc": frozenset( """ client.py constants.py directory.py discovery.py endpoints jwks.py persistence.py @@ -138,6 +133,11 @@ def _is_layout_shim(path: Path) -> bool: return "deprecated_module(" in text and "sys.modules[__name__]" in text +def _only_bytecode(path: Path) -> bool: + """A directory an older checkout left behind holding only ``__pycache__``.""" + return all(child.name == "__pycache__" for child in path.iterdir()) + + def _is_shim_package(path: Path) -> bool: """An old package path kept only for its aliases: shims plus a docstring __init__.""" files = [f for f in path.glob("*.py") if f.name != "__init__.py"] @@ -152,7 +152,12 @@ def test_module_root_layout(module: str) -> None: p.name for p in package.iterdir() if (p.suffix == ".py" and not _is_layout_shim(p)) - or (p.is_dir() and p.name != "__pycache__" and not _is_shim_package(p)) + or ( + p.is_dir() + and p.name != "__pycache__" + and not _only_bytecode(p) + and not _is_shim_package(p) + ) } allowed = ALLOWED_ROOT | DOMAIN_AREAS.get(module, frozenset()) legacy = LEGACY_FLAT.get(module, frozenset()) diff --git a/modules/auth/tests/test_auth_oidc_authority.py b/modules/auth/tests/test_auth_oidc_authority.py index 136a09d52..6b1d26898 100644 --- a/modules/auth/tests/test_auth_oidc_authority.py +++ b/modules/auth/tests/test_auth_oidc_authority.py @@ -7,8 +7,8 @@ import httpx import pytest from auth.contracts.schemas import UserContext -from keycloak.provider import KeycloakAuthProvider -from keycloak.settings import KeycloakSettings +from keycloak.core.settings import KeycloakSettings +from keycloak.identity.provider import KeycloakAuthProvider from oidc.provider import OidcAuthProvider from oidc.settings import OidcSettings from simple_module_hosting.session import SessionMiddleware diff --git a/modules/auth/tests/test_auth_oidc_jwks_cache.py b/modules/auth/tests/test_auth_oidc_jwks_cache.py index 68f8094f9..7f37484f1 100644 --- a/modules/auth/tests/test_auth_oidc_jwks_cache.py +++ b/modules/auth/tests/test_auth_oidc_jwks_cache.py @@ -9,7 +9,7 @@ import pytest from cryptography.hazmat.primitives.asymmetric import rsa from jwt.algorithms import RSAAlgorithm -from keycloak.jwks import JWKSCache as KeycloakJWKS +from keycloak.identity.jwks import JWKSCache as KeycloakJWKS from oidc.jwks import JWKSCache as OidcJWKS diff --git a/modules/auth/tests/test_auth_oidc_jwt_hardening.py b/modules/auth/tests/test_auth_oidc_jwt_hardening.py index 59c6f9396..0bb3fa64c 100644 --- a/modules/auth/tests/test_auth_oidc_jwt_hardening.py +++ b/modules/auth/tests/test_auth_oidc_jwt_hardening.py @@ -5,7 +5,7 @@ import jwt import pytest from cryptography.hazmat.primitives.asymmetric import rsa -from keycloak.jwks import JWKSCache as KeycloakJWKS +from keycloak.identity.jwks import JWKSCache as KeycloakJWKS from oidc.jwks import JWKSCache as OidcJWKS diff --git a/modules/auth/tests/test_auth_oidc_tokens.py b/modules/auth/tests/test_auth_oidc_tokens.py index 1297f9b5d..993cdb17d 100644 --- a/modules/auth/tests/test_auth_oidc_tokens.py +++ b/modules/auth/tests/test_auth_oidc_tokens.py @@ -7,7 +7,7 @@ from cryptography.fernet import Fernet from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.kdf.hkdf import HKDF -from keycloak import tokens as keycloak_tokens +from keycloak.identity import tokens as keycloak_tokens from oidc import tokens as oidc_tokens diff --git a/modules/keycloak/keycloak/constants.py b/modules/keycloak/keycloak/constants.py index 3583beba6..01f0bed84 100644 --- a/modules/keycloak/keycloak/constants.py +++ b/modules/keycloak/keycloak/constants.py @@ -1,22 +1,11 @@ -"""Keycloak module constants.""" +"""Moved to :mod:`keycloak.core.constants`; this alias goes in 0.0.46 (module layout).""" from __future__ import annotations -# Anonymous-allowed prefixes, registered in ``KeycloakModule.register_public_routes``. -PUBLIC_PATH_PREFIXES = ( - "/keycloak/login", - "/keycloak/logout", - # Where the end-session redirect lands: there is no session left to check, - # and bouncing it to /keycloak/login would sign the visitor straight back in. - "/keycloak/logged-out", - "/api/keycloak/auth/", -) +import importlib +import sys -SESSION_REFRESH_TOKEN_KEY = "keycloak_refresh_token" -SESSION_REFRESH_RETRY_AT_KEY = "keycloak_refresh_retry_at" -SESSION_ID_TOKEN_KEY = "keycloak_id_token" -SESSION_USER_KEY = "user_ctx" -SESSION_STATE_KEY = "keycloak_oidc_state" -SESSION_NONCE_KEY = "keycloak_oidc_nonce" -SESSION_VERIFIER_KEY = "keycloak_oidc_verifier" -SESSION_IDENTITY_KEY = "keycloak_identity" +from simple_module_core.deprecation import deprecated_module + +deprecated_module("keycloak.constants", remove_in="0.0.46", use="keycloak.core.constants") +sys.modules[__name__] = importlib.import_module("keycloak.core.constants") diff --git a/modules/keycloak/keycloak/core/__init__.py b/modules/keycloak/keycloak/core/__init__.py new file mode 100644 index 000000000..6228a49e9 --- /dev/null +++ b/modules/keycloak/keycloak/core/__init__.py @@ -0,0 +1 @@ +"""What the Keycloak provider builds on: constants, settings and app state.""" diff --git a/modules/keycloak/keycloak/core/constants.py b/modules/keycloak/keycloak/core/constants.py new file mode 100644 index 000000000..3583beba6 --- /dev/null +++ b/modules/keycloak/keycloak/core/constants.py @@ -0,0 +1,22 @@ +"""Keycloak module constants.""" + +from __future__ import annotations + +# Anonymous-allowed prefixes, registered in ``KeycloakModule.register_public_routes``. +PUBLIC_PATH_PREFIXES = ( + "/keycloak/login", + "/keycloak/logout", + # Where the end-session redirect lands: there is no session left to check, + # and bouncing it to /keycloak/login would sign the visitor straight back in. + "/keycloak/logged-out", + "/api/keycloak/auth/", +) + +SESSION_REFRESH_TOKEN_KEY = "keycloak_refresh_token" +SESSION_REFRESH_RETRY_AT_KEY = "keycloak_refresh_retry_at" +SESSION_ID_TOKEN_KEY = "keycloak_id_token" +SESSION_USER_KEY = "user_ctx" +SESSION_STATE_KEY = "keycloak_oidc_state" +SESSION_NONCE_KEY = "keycloak_oidc_nonce" +SESSION_VERIFIER_KEY = "keycloak_oidc_verifier" +SESSION_IDENTITY_KEY = "keycloak_identity" diff --git a/modules/keycloak/keycloak/core/settings.py b/modules/keycloak/keycloak/core/settings.py new file mode 100644 index 000000000..4c34ae4d5 --- /dev/null +++ b/modules/keycloak/keycloak/core/settings.py @@ -0,0 +1,82 @@ +"""Keycloak module settings -- DB-backed via ``register_module_settings``.""" + +from __future__ import annotations + +from pydantic import Field, field_validator, model_validator +from pydantic_settings import SettingsConfigDict +from simple_module_core.dotenv import env_bool, 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 + +DEFAULT_LOGIN_REDIRECT_URL = "/dashboard/" + + +class KeycloakSettings(DbBackedSettings): + """Keycloak OIDC configuration.""" + + # ``DbBackedSettings`` (not ``BaseSettings``) so the DB is genuinely the + # only source: omitting ``env_prefix`` would leave pydantic-settings + # reading each field from its bare name — GH #283. + model_config = SettingsConfigDict(extra="ignore") + + server_url: str = env_str("SM_KEYCLOAK_SERVER_URL", "") + realm: str = env_str("SM_KEYCLOAK_REALM", "") + client_id: str = env_str("SM_KEYCLOAK_CLIENT_ID", "") + client_secret: str = env_str("SM_KEYCLOAK_CLIENT_SECRET", "") + + # Off by default: the JWT ``tenant_id`` claim is only as trustworthy as the + # realm mapper that produces it. A mapper reading an attribute the user can + # edit lets them name their own tenant. Turn on only for a realm whose + # protocol mapper users cannot influence; the ``tenants`` module's + # resolver ignores the claim either way. + trust_tenant_claim: bool = env_bool("SM_KEYCLOAK_TRUST_TENANT_CLAIM", False) + + roles_claim_path: str = "realm_access.roles" + admin_role: str = "admin" + login_redirect_url: str = DEFAULT_LOGIN_REDIRECT_URL + jwks_cache_ttl_seconds: int = 3600 + + 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: + """Blank is never a usable navigation target. + + The callback puts this straight into a ``Location`` header, and an + admin can clear it in the generic module-settings editor. Normalising + on the class covers hydration and ``apply_changes_and_reload`` alike. + Users' provider has its own copy of this field and does the same. + """ + return non_empty_redirect(value, default=DEFAULT_LOGIN_REDIRECT_URL) + + @model_validator(mode="after") + def _check_required_in_production(self) -> KeycloakSettings: + import os + + env = os.environ.get("SM_ENVIRONMENT", "development") + if env in NON_PROD_ENVIRONMENTS: + return self + required = ( + ("server_url", "SM_KEYCLOAK_SERVER_URL"), + ("realm", "SM_KEYCLOAK_REALM"), + ("client_id", "SM_KEYCLOAK_CLIENT_ID"), + ("client_secret", "SM_KEYCLOAK_CLIENT_SECRET"), + ) + missing = [(field, var) for field, var in required if not getattr(self, field)] + if missing: + # Located on the first empty field so the form shows it there. + msg = f"Keycloak settings required in production: {', '.join(v for _, v in missing)}" + raise field_error( + type(self), + missing[0][0], + "keycloak_settings_required", + msg, + value="", + related=[field for field, _ in required], + ) + return self diff --git a/modules/keycloak/keycloak/state.py b/modules/keycloak/keycloak/core/state.py similarity index 77% rename from modules/keycloak/keycloak/state.py rename to modules/keycloak/keycloak/core/state.py index 024449afb..dcd719de0 100644 --- a/modules/keycloak/keycloak/state.py +++ b/modules/keycloak/keycloak/core/state.py @@ -6,8 +6,8 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from keycloak.jwks import JWKSCache - from keycloak.settings import KeycloakSettings + from keycloak.core.settings import KeycloakSettings + from keycloak.identity.jwks import JWKSCache @dataclass diff --git a/modules/keycloak/keycloak/identity/__init__.py b/modules/keycloak/keycloak/identity/__init__.py new file mode 100644 index 000000000..b55e31e35 --- /dev/null +++ b/modules/keycloak/keycloak/identity/__init__.py @@ -0,0 +1,5 @@ +"""The auth provider. + +OIDC client, token renewal and encryption, JWKS, the user directory and its +cache persistence. +""" diff --git a/modules/keycloak/keycloak/directory.py b/modules/keycloak/keycloak/identity/directory.py similarity index 100% rename from modules/keycloak/keycloak/directory.py rename to modules/keycloak/keycloak/identity/directory.py diff --git a/modules/keycloak/keycloak/identity/jwks.py b/modules/keycloak/keycloak/identity/jwks.py new file mode 100644 index 000000000..6c158864b --- /dev/null +++ b/modules/keycloak/keycloak/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/keycloak/keycloak/oidc.py b/modules/keycloak/keycloak/identity/oidc.py similarity index 100% rename from modules/keycloak/keycloak/oidc.py rename to modules/keycloak/keycloak/identity/oidc.py diff --git a/modules/keycloak/keycloak/persistence.py b/modules/keycloak/keycloak/identity/persistence.py similarity index 100% rename from modules/keycloak/keycloak/persistence.py rename to modules/keycloak/keycloak/identity/persistence.py diff --git a/modules/keycloak/keycloak/identity/provider.py b/modules/keycloak/keycloak/identity/provider.py new file mode 100644 index 000000000..424669af1 --- /dev/null +++ b/modules/keycloak/keycloak/identity/provider.py @@ -0,0 +1,129 @@ +"""KeycloakAuthProvider — resolves users from Keycloak 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_has_expired +from starlette.requests import Request + +from keycloak.core.constants import SESSION_USER_KEY as _SESSION_USER_CTX_KEY +from keycloak.identity.persistence import CachePersistence +from keycloak.identity.renewal import SessionRenewal + +if TYPE_CHECKING: + from keycloak.core.settings import KeycloakSettings + from keycloak.core.state import KeycloakState + from keycloak.identity.jwks import JWKSCache + +logger = logging.getLogger(__name__) + + +class KeycloakAuthProvider(CachePersistence, SessionRenewal): + """OIDC auth provider backed by Keycloak.""" + + name = "keycloak" + _is_auth_provider = True + + def __init__( + self, settings: KeycloakSettings | None = None, *, state: KeycloakState | 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) -> KeycloakSettings | 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 session_has_expired(session): + return await self._refresh_session(request) + ctx = UserContext.from_session_dict(session.get(_SESSION_USER_CTX_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 "/keycloak/login" + + def get_logout_url(self, request: Request | None) -> str: + return "/keycloak/logout" + + 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: + logger.warning("JWKS cache not initialized; rejecting bearer token") + return None + claims = await self.jwks_cache.validate_jwt(token) + if claims is None: + return None + + cache_id = await self._upsert_user_cache(request, claims, throttle_login=True) + 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 _claims_to_user_context( + self, + claims: dict[str, Any], + *, + cache_id: str, + ) -> UserContext: + roles_raw = ( + _extract_nested(claims, self._settings.roles_claim_path) if self._settings else None + ) + mapped = [ + self._settings.role_mapping[r] + for r in (roles_raw or []) + if self._settings and isinstance(r, str) and r in self._settings.role_mapping + ] + return UserContext( + id=cache_id, + email=claims.get("email", ""), + email_verified=claims.get("email_verified") is True, + name=(claims.get("preferred_username") or claims.get("name", "")), + roles=self._without_tenant_roles(mapped), + tenant_id=self._trusted_tenant(claims.get("tenant_id")), + ) + + 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="Keycloak") diff --git a/modules/keycloak/keycloak/renewal.py b/modules/keycloak/keycloak/identity/renewal.py similarity index 94% rename from modules/keycloak/keycloak/renewal.py rename to modules/keycloak/keycloak/identity/renewal.py index 5b72caba9..3d9e876a7 100644 --- a/modules/keycloak/keycloak/renewal.py +++ b/modules/keycloak/keycloak/identity/renewal.py @@ -8,15 +8,15 @@ from simple_module_db import independent_session from sqlmodel import select -from keycloak.constants import ( +from keycloak.core.constants import ( SESSION_ID_TOKEN_KEY, SESSION_IDENTITY_KEY, SESSION_REFRESH_RETRY_AT_KEY, SESSION_REFRESH_TOKEN_KEY, SESSION_USER_KEY, ) +from keycloak.identity.tokens import open_refresh_token, store_refresh_token from keycloak.models import KeycloakUserCache -from keycloak.tokens import open_refresh_token, store_refresh_token logger = logging.getLogger(__name__) @@ -50,7 +50,7 @@ def _accept_session(self, session) -> bool: ) async def _refresh_session(self, request): - from keycloak.endpoints.api import _get_oidc_client + from keycloak.web.endpoints.api import _get_oidc_client async def refresh(token): return await _get_oidc_client(request).refresh_tokens(token) diff --git a/modules/keycloak/keycloak/identity/tokens.py b/modules/keycloak/keycloak/identity/tokens.py new file mode 100644 index 000000000..c5367c1f1 --- /dev/null +++ b/modules/keycloak/keycloak/identity/tokens.py @@ -0,0 +1,10 @@ +"""Provider-scoped refresh-token encryption; preserves existing session ciphertext.""" + +from auth.oidc_common.tokens import provider_tokens + +from keycloak.core.constants import SESSION_REFRESH_TOKEN_KEY + +_TOKENS = provider_tokens(info=b"keycloak-refresh-token", session_key=SESSION_REFRESH_TOKEN_KEY) +seal_refresh_token = _TOKENS.seal_refresh_token +open_refresh_token = _TOKENS.open_refresh_token +store_refresh_token = _TOKENS.store_refresh_token diff --git a/modules/keycloak/keycloak/jwks.py b/modules/keycloak/keycloak/jwks.py index 6c158864b..02f1b9015 100644 --- a/modules/keycloak/keycloak/jwks.py +++ b/modules/keycloak/keycloak/jwks.py @@ -1,5 +1,11 @@ -"""Provider entry point for the shared JWKS validator.""" +"""Moved to :mod:`keycloak.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("keycloak.jwks", remove_in="0.0.46", use="keycloak.identity.jwks") +sys.modules[__name__] = importlib.import_module("keycloak.identity.jwks") diff --git a/modules/keycloak/keycloak/module.py b/modules/keycloak/keycloak/module.py index 8e12e3bf6..4d1b4d25a 100644 --- a/modules/keycloak/keycloak/module.py +++ b/modules/keycloak/keycloak/module.py @@ -11,7 +11,7 @@ from simple_module_core.module_settings import register_module_settings from simple_module_core.public_routes import PublicRouteRegistry -from keycloak.constants import PUBLIC_PATH_PREFIXES +from keycloak.core.constants import PUBLIC_PATH_PREFIXES if TYPE_CHECKING: from fastapi import APIRouter, FastAPI @@ -32,9 +32,9 @@ class KeycloakModule(ModuleBase): _is_auth_provider = True def register_settings(self, app: FastAPI) -> None: - from keycloak.provider import KeycloakAuthProvider - from keycloak.settings import KeycloakSettings - from keycloak.state import KeycloakState + from keycloak.core.settings import KeycloakSettings + from keycloak.core.state import KeycloakState + from keycloak.identity.provider import KeycloakAuthProvider register_module_settings( app, @@ -50,7 +50,7 @@ def register_settings(self, app: FastAPI) -> None: # Other modules read accounts through the directory (GH #501). from simple_module_core.user_directory import set_user_directory - from keycloak.directory import KeycloakDirectory + from keycloak.identity.directory import KeycloakDirectory set_user_directory(app, KeycloakDirectory()) @@ -72,14 +72,14 @@ def register_menu_items(self, registry: MenuRegistry) -> None: ) def register_routes(self, api_router: APIRouter, view_router: APIRouter) -> None: - from keycloak.endpoints.api import router as api - from keycloak.endpoints.views import router as views + from keycloak.web.endpoints.api import router as api + from keycloak.web.endpoints.views import router as views api_router.include_router(api) view_router.include_router(views) async def on_startup(self, app: FastAPI) -> None: - from keycloak.jwks import JWKSCache + from keycloak.identity.jwks import JWKSCache state = app.state.keycloak s = state.settings diff --git a/modules/keycloak/keycloak/provider.py b/modules/keycloak/keycloak/provider.py index 827142923..333c4c734 100644 --- a/modules/keycloak/keycloak/provider.py +++ b/modules/keycloak/keycloak/provider.py @@ -1,129 +1,11 @@ -"""KeycloakAuthProvider — resolves users from Keycloak JWTs or session.""" +"""Moved to :mod:`keycloak.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_has_expired -from starlette.requests import Request +from simple_module_core.deprecation import deprecated_module -from keycloak.constants import SESSION_USER_KEY as _SESSION_USER_CTX_KEY -from keycloak.persistence import CachePersistence -from keycloak.renewal import SessionRenewal - -if TYPE_CHECKING: - from keycloak.jwks import JWKSCache - from keycloak.settings import KeycloakSettings - from keycloak.state import KeycloakState - -logger = logging.getLogger(__name__) - - -class KeycloakAuthProvider(CachePersistence, SessionRenewal): - """OIDC auth provider backed by Keycloak.""" - - name = "keycloak" - _is_auth_provider = True - - def __init__( - self, settings: KeycloakSettings | None = None, *, state: KeycloakState | 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) -> KeycloakSettings | 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 session_has_expired(session): - return await self._refresh_session(request) - ctx = UserContext.from_session_dict(session.get(_SESSION_USER_CTX_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 "/keycloak/login" - - def get_logout_url(self, request: Request | None) -> str: - return "/keycloak/logout" - - 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: - logger.warning("JWKS cache not initialized; rejecting bearer token") - return None - claims = await self.jwks_cache.validate_jwt(token) - if claims is None: - return None - - cache_id = await self._upsert_user_cache(request, claims, throttle_login=True) - 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 _claims_to_user_context( - self, - claims: dict[str, Any], - *, - cache_id: str, - ) -> UserContext: - roles_raw = ( - _extract_nested(claims, self._settings.roles_claim_path) if self._settings else None - ) - mapped = [ - self._settings.role_mapping[r] - for r in (roles_raw or []) - if self._settings and isinstance(r, str) and r in self._settings.role_mapping - ] - return UserContext( - id=cache_id, - email=claims.get("email", ""), - email_verified=claims.get("email_verified") is True, - name=(claims.get("preferred_username") or claims.get("name", "")), - roles=self._without_tenant_roles(mapped), - tenant_id=self._trusted_tenant(claims.get("tenant_id")), - ) - - 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="Keycloak") +deprecated_module("keycloak.provider", remove_in="0.0.46", use="keycloak.identity.provider") +sys.modules[__name__] = importlib.import_module("keycloak.identity.provider") diff --git a/modules/keycloak/keycloak/settings.py b/modules/keycloak/keycloak/settings.py index 4c34ae4d5..e82c6dee9 100644 --- a/modules/keycloak/keycloak/settings.py +++ b/modules/keycloak/keycloak/settings.py @@ -1,82 +1,11 @@ -"""Keycloak module settings -- DB-backed via ``register_module_settings``.""" +"""Moved to :mod:`keycloak.core.settings`; this alias goes in 0.0.46 (module layout).""" from __future__ import annotations -from pydantic import Field, field_validator, model_validator -from pydantic_settings import SettingsConfigDict -from simple_module_core.dotenv import env_bool, 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 +import importlib +import sys -DEFAULT_LOGIN_REDIRECT_URL = "/dashboard/" +from simple_module_core.deprecation import deprecated_module - -class KeycloakSettings(DbBackedSettings): - """Keycloak OIDC configuration.""" - - # ``DbBackedSettings`` (not ``BaseSettings``) so the DB is genuinely the - # only source: omitting ``env_prefix`` would leave pydantic-settings - # reading each field from its bare name — GH #283. - model_config = SettingsConfigDict(extra="ignore") - - server_url: str = env_str("SM_KEYCLOAK_SERVER_URL", "") - realm: str = env_str("SM_KEYCLOAK_REALM", "") - client_id: str = env_str("SM_KEYCLOAK_CLIENT_ID", "") - client_secret: str = env_str("SM_KEYCLOAK_CLIENT_SECRET", "") - - # Off by default: the JWT ``tenant_id`` claim is only as trustworthy as the - # realm mapper that produces it. A mapper reading an attribute the user can - # edit lets them name their own tenant. Turn on only for a realm whose - # protocol mapper users cannot influence; the ``tenants`` module's - # resolver ignores the claim either way. - trust_tenant_claim: bool = env_bool("SM_KEYCLOAK_TRUST_TENANT_CLAIM", False) - - roles_claim_path: str = "realm_access.roles" - admin_role: str = "admin" - login_redirect_url: str = DEFAULT_LOGIN_REDIRECT_URL - jwks_cache_ttl_seconds: int = 3600 - - 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: - """Blank is never a usable navigation target. - - The callback puts this straight into a ``Location`` header, and an - admin can clear it in the generic module-settings editor. Normalising - on the class covers hydration and ``apply_changes_and_reload`` alike. - Users' provider has its own copy of this field and does the same. - """ - return non_empty_redirect(value, default=DEFAULT_LOGIN_REDIRECT_URL) - - @model_validator(mode="after") - def _check_required_in_production(self) -> KeycloakSettings: - import os - - env = os.environ.get("SM_ENVIRONMENT", "development") - if env in NON_PROD_ENVIRONMENTS: - return self - required = ( - ("server_url", "SM_KEYCLOAK_SERVER_URL"), - ("realm", "SM_KEYCLOAK_REALM"), - ("client_id", "SM_KEYCLOAK_CLIENT_ID"), - ("client_secret", "SM_KEYCLOAK_CLIENT_SECRET"), - ) - missing = [(field, var) for field, var in required if not getattr(self, field)] - if missing: - # Located on the first empty field so the form shows it there. - msg = f"Keycloak settings required in production: {', '.join(v for _, v in missing)}" - raise field_error( - type(self), - missing[0][0], - "keycloak_settings_required", - msg, - value="", - related=[field for field, _ in required], - ) - return self +deprecated_module("keycloak.settings", remove_in="0.0.46", use="keycloak.core.settings") +sys.modules[__name__] = importlib.import_module("keycloak.core.settings") diff --git a/modules/keycloak/keycloak/tokens.py b/modules/keycloak/keycloak/tokens.py index db1a60369..256f6b003 100644 --- a/modules/keycloak/keycloak/tokens.py +++ b/modules/keycloak/keycloak/tokens.py @@ -1,10 +1,11 @@ -"""Provider-scoped refresh-token encryption; preserves existing session ciphertext.""" +"""Moved to :mod:`keycloak.identity.tokens`; this alias goes in 0.0.46 (module layout).""" -from auth.oidc_common.tokens import provider_tokens +from __future__ import annotations -from keycloak.constants import SESSION_REFRESH_TOKEN_KEY +import importlib +import sys -_TOKENS = provider_tokens(info=b"keycloak-refresh-token", session_key=SESSION_REFRESH_TOKEN_KEY) -seal_refresh_token = _TOKENS.seal_refresh_token -open_refresh_token = _TOKENS.open_refresh_token -store_refresh_token = _TOKENS.store_refresh_token +from simple_module_core.deprecation import deprecated_module + +deprecated_module("keycloak.tokens", remove_in="0.0.46", use="keycloak.identity.tokens") +sys.modules[__name__] = importlib.import_module("keycloak.identity.tokens") diff --git a/modules/keycloak/keycloak/web/__init__.py b/modules/keycloak/keycloak/web/__init__.py new file mode 100644 index 000000000..acd75a07e --- /dev/null +++ b/modules/keycloak/keycloak/web/__init__.py @@ -0,0 +1 @@ +"""The HTTP side: the REST and Inertia endpoints.""" diff --git a/modules/keycloak/keycloak/endpoints/__init__.py b/modules/keycloak/keycloak/web/endpoints/__init__.py similarity index 100% rename from modules/keycloak/keycloak/endpoints/__init__.py rename to modules/keycloak/keycloak/web/endpoints/__init__.py diff --git a/modules/keycloak/keycloak/endpoints/api.py b/modules/keycloak/keycloak/web/endpoints/api.py similarity index 93% rename from modules/keycloak/keycloak/endpoints/api.py rename to modules/keycloak/keycloak/web/endpoints/api.py index 562cd3640..92e99c0e8 100644 --- a/modules/keycloak/keycloak/endpoints/api.py +++ b/modules/keycloak/keycloak/web/endpoints/api.py @@ -11,23 +11,23 @@ from fastapi import APIRouter, HTTPException, Request from starlette.responses import RedirectResponse -from keycloak.constants import ( +from keycloak.core.constants import ( SESSION_IDENTITY_KEY, SESSION_VERIFIER_KEY, ) -from keycloak.constants import ( +from keycloak.core.constants import ( SESSION_NONCE_KEY as _SESSION_OIDC_NONCE, ) -from keycloak.constants import ( +from keycloak.core.constants import ( SESSION_STATE_KEY as _SESSION_OIDC_STATE, ) -from keycloak.constants import ( +from keycloak.core.constants import ( SESSION_USER_KEY as _SESSION_USER_CTX, ) -from keycloak.tokens import store_refresh_token +from keycloak.identity.tokens import store_refresh_token if TYPE_CHECKING: - from keycloak.settings import KeycloakSettings + from keycloak.core.settings import KeycloakSettings logger = logging.getLogger(__name__) router = APIRouter(prefix="/auth", tags=["keycloak-auth"]) @@ -38,7 +38,7 @@ def _get_settings(request: Request) -> KeycloakSettings: def _get_oidc_client(request: Request): - from keycloak.oidc import OIDCClient + from keycloak.identity.oidc import OIDCClient s = _get_settings(request) return OIDCClient( diff --git a/modules/keycloak/keycloak/endpoints/views.py b/modules/keycloak/keycloak/web/endpoints/views.py similarity index 94% rename from modules/keycloak/keycloak/endpoints/views.py rename to modules/keycloak/keycloak/web/endpoints/views.py index 0eccbc4b1..182d7e262 100644 --- a/modules/keycloak/keycloak/endpoints/views.py +++ b/modules/keycloak/keycloak/web/endpoints/views.py @@ -10,8 +10,8 @@ from simple_module_inertia.response import location from starlette.responses import RedirectResponse, Response -from keycloak.renewal import _KEYS -from keycloak.tokens import SESSION_REFRESH_TOKEN_KEY, open_refresh_token +from keycloak.identity.renewal import _KEYS +from keycloak.identity.tokens import SESSION_REFRESH_TOKEN_KEY, open_refresh_token logger = logging.getLogger(__name__) router = APIRouter(tags=["keycloak-views"]) @@ -52,7 +52,7 @@ async def logged_out_page(inertia: InertiaDep): @router.post("/logout") async def logout(request: Request) -> Response: - from keycloak.oidc import OIDCClient + from keycloak.identity.oidc import OIDCClient s = request.app.state.keycloak.settings refresh_token = open_refresh_token(request.app, request.session.get(SESSION_REFRESH_TOKEN_KEY)) diff --git a/modules/keycloak/tests/test_keycloak_compat_741.py b/modules/keycloak/tests/core/module/test_keycloak_compat_741.py similarity index 92% rename from modules/keycloak/tests/test_keycloak_compat_741.py rename to modules/keycloak/tests/core/module/test_keycloak_compat_741.py index 8781bb1b4..94dae0a7e 100644 --- a/modules/keycloak/tests/test_keycloak_compat_741.py +++ b/modules/keycloak/tests/core/module/test_keycloak_compat_741.py @@ -10,12 +10,12 @@ from auth.contracts.schemas import UserContext from cryptography.hazmat.primitives.asymmetric import rsa from fastapi import HTTPException -from keycloak.endpoints import api -from keycloak.jwks import JWKSCache as KeycloakJWKS +from keycloak.core.settings import KeycloakSettings +from keycloak.identity.jwks import JWKSCache as KeycloakJWKS +from keycloak.identity.provider import KeycloakAuthProvider +from keycloak.identity.tokens import seal_refresh_token from keycloak.module import KeycloakModule -from keycloak.provider import KeycloakAuthProvider -from keycloak.settings import KeycloakSettings -from keycloak.tokens import seal_refresh_token +from keycloak.web.endpoints import api from oidc.jwks import JWKSCache as OidcJWKS from starlette.requests import Request @@ -86,7 +86,7 @@ async def test_refresh_matches_session_whose_id_is_the_raw_sub(monkeypatch, db_s } request = Request({"type": "http", "headers": [], "session": session, "app": app}) monkeypatch.setattr( - "keycloak.endpoints.api._get_oidc_client", + "keycloak.web.endpoints.api._get_oidc_client", lambda _r: SimpleNamespace(refresh_tokens=AsyncMock(return_value={"access_token": "a"})), ) principal = await provider.resolve_user(request) @@ -141,7 +141,7 @@ async def test_refresh_survives_an_unavailable_identity_check(monkeypatch, db_st def broken_session(_app): raise RuntimeError("database unavailable") - monkeypatch.setattr("keycloak.renewal.independent_session", broken_session) + monkeypatch.setattr("keycloak.identity.renewal.independent_session", broken_session) cached_id = "6f1c6a9e-5b7e-4c3b-9d8f-2a1e3c4b5d6e" session = { "user_ctx": UserContext(id=cached_id, email="", name="", roles=[]).to_session_dict(), @@ -151,7 +151,7 @@ def broken_session(_app): } request = Request({"type": "http", "headers": [], "session": session, "app": app}) monkeypatch.setattr( - "keycloak.endpoints.api._get_oidc_client", + "keycloak.web.endpoints.api._get_oidc_client", lambda _r: SimpleNamespace(refresh_tokens=AsyncMock(return_value={"access_token": "a"})), ) principal = await provider.resolve_user(request) diff --git a/modules/keycloak/tests/test_keycloak_module.py b/modules/keycloak/tests/core/module/test_keycloak_module.py similarity index 88% rename from modules/keycloak/tests/test_keycloak_module.py rename to modules/keycloak/tests/core/module/test_keycloak_module.py index 98492d486..5c96fc44f 100644 --- a/modules/keycloak/tests/test_keycloak_module.py +++ b/modules/keycloak/tests/core/module/test_keycloak_module.py @@ -16,7 +16,7 @@ def test_keycloak_module_meta(): def test_keycloak_provider_satisfies_protocol(): - from keycloak.provider import KeycloakAuthProvider + from keycloak.identity.provider import KeycloakAuthProvider provider = KeycloakAuthProvider() assert isinstance(provider, AuthProvider) @@ -33,17 +33,17 @@ class TestKeycloakLoginRedirectUrl: """ def test_blank_falls_back_to_the_default(self): - from keycloak.settings import KeycloakSettings + from keycloak.core.settings import KeycloakSettings assert KeycloakSettings(login_redirect_url="").login_redirect_url == "/dashboard/" def test_whitespace_only_falls_back_to_the_default(self): - from keycloak.settings import KeycloakSettings + from keycloak.core.settings import KeycloakSettings assert KeycloakSettings(login_redirect_url=" ").login_redirect_url == "/dashboard/" def test_a_real_value_is_left_alone(self): - from keycloak.settings import KeycloakSettings + from keycloak.core.settings import KeycloakSettings assert KeycloakSettings(login_redirect_url="/home/").login_redirect_url == "/home/" diff --git a/modules/keycloak/tests/test_keycloak_strict_boot.py b/modules/keycloak/tests/core/module/test_keycloak_strict_boot.py similarity index 97% rename from modules/keycloak/tests/test_keycloak_strict_boot.py rename to modules/keycloak/tests/core/module/test_keycloak_strict_boot.py index f9eb1f8c5..55fc452e9 100644 --- a/modules/keycloak/tests/test_keycloak_strict_boot.py +++ b/modules/keycloak/tests/core/module/test_keycloak_strict_boot.py @@ -9,7 +9,7 @@ from __future__ import annotations import pytest -from keycloak.directory import KeycloakDirectory +from keycloak.identity.directory import KeycloakDirectory from keycloak_test_modules import keycloak_modules_enabled from simple_module_core.exceptions import MissingDependencyError from simple_module_core.user_directory import get_user_directory diff --git a/modules/keycloak/tests/test_keycloak_directory.py b/modules/keycloak/tests/identity/directory/test_keycloak_directory.py similarity index 98% rename from modules/keycloak/tests/test_keycloak_directory.py rename to modules/keycloak/tests/identity/directory/test_keycloak_directory.py index 977c0cb72..db659350d 100644 --- a/modules/keycloak/tests/test_keycloak_directory.py +++ b/modules/keycloak/tests/identity/directory/test_keycloak_directory.py @@ -7,7 +7,7 @@ from audit_log.models import AuditEntry from audit_log.resolve import actor_filter, actor_link, resolve_actors -from keycloak.directory import KeycloakDirectory +from keycloak.identity.directory import KeycloakDirectory from keycloak.models import KeycloakUserCache from simple_module_core.audit_links import AuditLink, AuditLinkRegistry from sqlalchemy import select diff --git a/modules/keycloak/tests/test_jwks.py b/modules/keycloak/tests/identity/jwks/test_keycloak_jwks.py similarity index 99% rename from modules/keycloak/tests/test_jwks.py rename to modules/keycloak/tests/identity/jwks/test_keycloak_jwks.py index eaf8fecc3..241abda7e 100644 --- a/modules/keycloak/tests/test_jwks.py +++ b/modules/keycloak/tests/identity/jwks/test_keycloak_jwks.py @@ -11,7 +11,7 @@ import jwt import pytest from cryptography.hazmat.primitives.asymmetric import rsa -from keycloak.jwks import JWKSCache +from keycloak.identity.jwks import JWKSCache def _generate_rsa_keypair(): diff --git a/modules/keycloak/tests/test_keycloak_email_verification.py b/modules/keycloak/tests/identity/oidc/test_keycloak_email_verification.py similarity index 93% rename from modules/keycloak/tests/test_keycloak_email_verification.py rename to modules/keycloak/tests/identity/oidc/test_keycloak_email_verification.py index 7034005db..4556a8ed9 100644 --- a/modules/keycloak/tests/test_keycloak_email_verification.py +++ b/modules/keycloak/tests/identity/oidc/test_keycloak_email_verification.py @@ -2,7 +2,7 @@ import pytest from auth.contracts.schemas import UserContext -from keycloak.provider import KeycloakAuthProvider +from keycloak.identity.provider import KeycloakAuthProvider @pytest.mark.parametrize("claim", [True, False, None, "true", "false", 1]) diff --git a/modules/keycloak/tests/test_oidc.py b/modules/keycloak/tests/identity/oidc/test_keycloak_oidc.py similarity index 98% rename from modules/keycloak/tests/test_oidc.py rename to modules/keycloak/tests/identity/oidc/test_keycloak_oidc.py index 7683bbdef..c86769dc7 100644 --- a/modules/keycloak/tests/test_oidc.py +++ b/modules/keycloak/tests/identity/oidc/test_keycloak_oidc.py @@ -3,7 +3,7 @@ from __future__ import annotations import pytest -from keycloak.oidc import OIDCClient +from keycloak.identity.oidc import OIDCClient @pytest.fixture diff --git a/modules/keycloak/tests/test_keycloak_oidc_hardening.py b/modules/keycloak/tests/identity/oidc/test_keycloak_oidc_hardening.py similarity index 96% rename from modules/keycloak/tests/test_keycloak_oidc_hardening.py rename to modules/keycloak/tests/identity/oidc/test_keycloak_oidc_hardening.py index a1649fe62..dddae41fb 100644 --- a/modules/keycloak/tests/test_keycloak_oidc_hardening.py +++ b/modules/keycloak/tests/identity/oidc/test_keycloak_oidc_hardening.py @@ -7,8 +7,8 @@ import pytest from fastapi import HTTPException -from keycloak.endpoints import api -from keycloak.oidc import OIDCClient +from keycloak.identity.oidc import OIDCClient +from keycloak.web.endpoints import api from starlette.requests import Request diff --git a/modules/keycloak/tests/test_keycloak_cache_persistence.py b/modules/keycloak/tests/identity/persistence/test_keycloak_cache_persistence.py similarity index 98% rename from modules/keycloak/tests/test_keycloak_cache_persistence.py rename to modules/keycloak/tests/identity/persistence/test_keycloak_cache_persistence.py index b2e14cf82..f7943585a 100644 --- a/modules/keycloak/tests/test_keycloak_cache_persistence.py +++ b/modules/keycloak/tests/identity/persistence/test_keycloak_cache_persistence.py @@ -4,9 +4,9 @@ from types import SimpleNamespace import pytest -from keycloak.directory import KeycloakDirectory +from keycloak.identity.directory import KeycloakDirectory +from keycloak.identity.provider import KeycloakAuthProvider from keycloak.models import KeycloakUserCache -from keycloak.provider import KeycloakAuthProvider from simple_module_db import independent_session from sqlalchemy import event, select from starlette.requests import Request diff --git a/modules/keycloak/tests/test_keycloak_api_tokens.py b/modules/keycloak/tests/identity/provider/test_keycloak_api_tokens.py similarity index 100% rename from modules/keycloak/tests/test_keycloak_api_tokens.py rename to modules/keycloak/tests/identity/provider/test_keycloak_api_tokens.py diff --git a/modules/keycloak/tests/test_keycloak_bearer_kind.py b/modules/keycloak/tests/identity/provider/test_keycloak_bearer_kind.py similarity index 90% rename from modules/keycloak/tests/test_keycloak_bearer_kind.py rename to modules/keycloak/tests/identity/provider/test_keycloak_bearer_kind.py index 2902dbb7a..e22f6e101 100644 --- a/modules/keycloak/tests/test_keycloak_bearer_kind.py +++ b/modules/keycloak/tests/identity/provider/test_keycloak_bearer_kind.py @@ -8,9 +8,9 @@ import pytest from auth.contracts.tokens import AUTH_KIND_BEARER -from keycloak.provider import KeycloakAuthProvider -from keycloak.settings import KeycloakSettings -from keycloak.state import KeycloakState +from keycloak.core.settings import KeycloakSettings +from keycloak.core.state import KeycloakState +from keycloak.identity.provider import KeycloakAuthProvider from starlette.requests import Request diff --git a/modules/keycloak/tests/test_keycloak_provider.py b/modules/keycloak/tests/identity/provider/test_keycloak_provider.py similarity index 94% rename from modules/keycloak/tests/test_keycloak_provider.py rename to modules/keycloak/tests/identity/provider/test_keycloak_provider.py index 5a90380f7..90df4c890 100644 --- a/modules/keycloak/tests/test_keycloak_provider.py +++ b/modules/keycloak/tests/identity/provider/test_keycloak_provider.py @@ -7,8 +7,8 @@ import pytest from auth.contracts.provider import AuthProvider from auth.contracts.schemas import UserContext -from keycloak.provider import KeycloakAuthProvider -from keycloak.settings import KeycloakSettings +from keycloak.core.settings import KeycloakSettings +from keycloak.identity.provider import KeycloakAuthProvider @pytest.fixture @@ -116,7 +116,10 @@ def test_env_var_turns_it_on(self): import subprocess import sys - code = "from keycloak.settings import KeycloakSettings as K; print(K().trust_tenant_claim)" + code = ( + "from keycloak.core.settings import KeycloakSettings as K; " + "print(K().trust_tenant_claim)" + ) env = {**os.environ, "SM_KEYCLOAK_TRUST_TENANT_CLAIM": "true"} out = subprocess.run( [sys.executable, "-c", code], env=env, capture_output=True, text=True, check=True diff --git a/modules/keycloak/tests/test_tenant_claim_resolver.py b/modules/keycloak/tests/identity/provider/test_keycloak_tenant_claim_resolver.py similarity index 94% rename from modules/keycloak/tests/test_tenant_claim_resolver.py rename to modules/keycloak/tests/identity/provider/test_keycloak_tenant_claim_resolver.py index ece2d987a..eb2ed749e 100644 --- a/modules/keycloak/tests/test_tenant_claim_resolver.py +++ b/modules/keycloak/tests/identity/provider/test_keycloak_tenant_claim_resolver.py @@ -6,8 +6,8 @@ from __future__ import annotations -from keycloak.provider import KeycloakAuthProvider -from keycloak.settings import KeycloakSettings +from keycloak.core.settings import KeycloakSettings +from keycloak.identity.provider import KeycloakAuthProvider from starlette.requests import Request _USER_ID = "11111111-1111-1111-1111-111111111111" diff --git a/modules/keycloak/tests/test_keycloak_tenant_hardening.py b/modules/keycloak/tests/identity/provider/test_keycloak_tenant_hardening.py similarity index 95% rename from modules/keycloak/tests/test_keycloak_tenant_hardening.py rename to modules/keycloak/tests/identity/provider/test_keycloak_tenant_hardening.py index 779aae9a6..2dc9b5fb0 100644 --- a/modules/keycloak/tests/test_keycloak_tenant_hardening.py +++ b/modules/keycloak/tests/identity/provider/test_keycloak_tenant_hardening.py @@ -13,9 +13,9 @@ import pytest from auth.contracts.schemas import UserContext -from keycloak.provider import KeycloakAuthProvider -from keycloak.settings import KeycloakSettings -from keycloak.state import KeycloakState +from keycloak.core.settings import KeycloakSettings +from keycloak.core.state import KeycloakState +from keycloak.identity.provider import KeycloakAuthProvider from keycloak_test_modules import keycloak_modules_enabled from starlette.requests import Request @@ -88,7 +88,7 @@ def test_role_mapping_cannot_grant_tenant_roles(caplog): provider = KeycloakAuthProvider(settings) claims = {"sub": "s", "realm_access": {"roles": ["kc-owner", "kc-admin", "kc-user"]}} - with caplog.at_level(logging.WARNING, logger="keycloak.provider"): + with caplog.at_level(logging.WARNING, logger="keycloak.identity.provider"): first = provider._claims_to_user_context(claims, cache_id="a") second = provider._claims_to_user_context(claims, cache_id="a") diff --git a/modules/keycloak/tests/test_keycloak_refresh.py b/modules/keycloak/tests/identity/renewal/test_keycloak_refresh.py similarity index 97% rename from modules/keycloak/tests/test_keycloak_refresh.py rename to modules/keycloak/tests/identity/renewal/test_keycloak_refresh.py index 258613fb0..6963100c3 100644 --- a/modules/keycloak/tests/test_keycloak_refresh.py +++ b/modules/keycloak/tests/identity/renewal/test_keycloak_refresh.py @@ -7,9 +7,9 @@ import httpx import pytest from auth.contracts.schemas import UserContext -from keycloak.provider import KeycloakAuthProvider -from keycloak.settings import KeycloakSettings -from keycloak.tokens import open_refresh_token, seal_refresh_token +from keycloak.core.settings import KeycloakSettings +from keycloak.identity.provider import KeycloakAuthProvider +from keycloak.identity.tokens import open_refresh_token, seal_refresh_token from starlette.requests import Request diff --git a/modules/keycloak/tests/test_keycloak_csrf_rotation.py b/modules/keycloak/tests/web/endpoints/test_keycloak_csrf_rotation.py similarity index 98% rename from modules/keycloak/tests/test_keycloak_csrf_rotation.py rename to modules/keycloak/tests/web/endpoints/test_keycloak_csrf_rotation.py index 60f213696..379d05e0a 100644 --- a/modules/keycloak/tests/test_keycloak_csrf_rotation.py +++ b/modules/keycloak/tests/web/endpoints/test_keycloak_csrf_rotation.py @@ -4,7 +4,7 @@ from types import SimpleNamespace from unittest.mock import AsyncMock -from keycloak.endpoints import api +from keycloak.web.endpoints import api from simple_module_hosting.csrf import get_csrf_token from starlette.requests import Request diff --git a/modules/keycloak/tests/test_keycloak_logout_revoke.py b/modules/keycloak/tests/web/endpoints/test_keycloak_logout_revoke.py similarity index 95% rename from modules/keycloak/tests/test_keycloak_logout_revoke.py rename to modules/keycloak/tests/web/endpoints/test_keycloak_logout_revoke.py index 93631bde0..ddfc9bacd 100644 --- a/modules/keycloak/tests/test_keycloak_logout_revoke.py +++ b/modules/keycloak/tests/web/endpoints/test_keycloak_logout_revoke.py @@ -4,9 +4,9 @@ from urllib.parse import parse_qs import httpx -from keycloak.endpoints import views -from keycloak.settings import KeycloakSettings -from keycloak.tokens import seal_refresh_token +from keycloak.core.settings import KeycloakSettings +from keycloak.identity.tokens import seal_refresh_token +from keycloak.web.endpoints import views from starlette.requests import Request _LOGOUT = "https://idp.test/realms/test/protocol/openid-connect/logout" diff --git a/modules/keycloak/tests/test_keycloak_session_security.py b/modules/keycloak/tests/web/endpoints/test_keycloak_session_security.py similarity index 97% rename from modules/keycloak/tests/test_keycloak_session_security.py rename to modules/keycloak/tests/web/endpoints/test_keycloak_session_security.py index a89da82b6..df51afca2 100644 --- a/modules/keycloak/tests/test_keycloak_session_security.py +++ b/modules/keycloak/tests/web/endpoints/test_keycloak_session_security.py @@ -11,11 +11,11 @@ from cryptography.hazmat.primitives.asymmetric import rsa from fastapi import HTTPException from jwt.algorithms import RSAAlgorithm -from keycloak.endpoints import api -from keycloak.jwks import JWKSCache -from keycloak.provider import KeycloakAuthProvider -from keycloak.settings import KeycloakSettings -from keycloak.tokens import open_refresh_token +from keycloak.core.settings import KeycloakSettings +from keycloak.identity.jwks import JWKSCache +from keycloak.identity.provider import KeycloakAuthProvider +from keycloak.identity.tokens import open_refresh_token +from keycloak.web.endpoints import api from simple_module_hosting.csrf import get_csrf_token from simple_module_hosting.session import preserve_across_sign_in from starlette.requests import Request @@ -244,7 +244,7 @@ async def app(scope, receive, send): async def test_keycloak_callback_sends_saved_pkce_verifier(callback, monkeypatch, httpx_mock): from urllib.parse import parse_qs, urlencode, urlsplit - from keycloak.oidc import OIDCClient + from keycloak.identity.oidc import OIDCClient request, _, id_claims = callback client = OIDCClient("https://idp.test", "test", "app", "secret") diff --git a/modules/keycloak/tests/test_keycloak_views.py b/modules/keycloak/tests/web/endpoints/test_keycloak_views.py similarity index 100% rename from modules/keycloak/tests/test_keycloak_views.py rename to modules/keycloak/tests/web/endpoints/test_keycloak_views.py diff --git a/modules/oidc/tests/test_oidc_refresh.py b/modules/oidc/tests/test_oidc_refresh.py index 0f8cfb223..6905bb0bd 100644 --- a/modules/oidc/tests/test_oidc_refresh.py +++ b/modules/oidc/tests/test_oidc_refresh.py @@ -203,7 +203,7 @@ async def test_session_deadline_never_slides(refresh_session, monkeypatch): async def test_refresh_sealing_is_provider_scoped(refresh_session): - from keycloak.tokens import open_refresh_token as open_keycloak_token + from keycloak.identity.tokens import open_refresh_token as open_keycloak_token _, request = refresh_session sealed = request.session["oidc_refresh_token"] diff --git a/modules/settings/tests/test_lenient_related_fields_validators.py b/modules/settings/tests/test_lenient_related_fields_validators.py index 7daf471dc..31703de78 100644 --- a/modules/settings/tests/test_lenient_related_fields_validators.py +++ b/modules/settings/tests/test_lenient_related_fields_validators.py @@ -9,7 +9,7 @@ import pytest from background_tasks.settings import ENV_PREFIX, BackgroundTasksSettings from file_storage.settings import FileStorageSettings -from keycloak.settings import KeycloakSettings +from keycloak.core.settings import KeycloakSettings from pydantic import ValidationError from simple_module_core.settings_hydrate import RELATED_FIELDS_CTX, build_settings from simple_module_core.stored_settings import construct_leniently diff --git a/modules/users/tests/test_users_login_deep_link.py b/modules/users/tests/test_users_login_deep_link.py index b82bc1a8c..a95e066f7 100644 --- a/modules/users/tests/test_users_login_deep_link.py +++ b/modules/users/tests/test_users_login_deep_link.py @@ -120,7 +120,8 @@ def test_redirecting_providers_sanitise_the_target(self, relative: str) -> None: @pytest.mark.parametrize( - "relative", ["modules/keycloak/keycloak/endpoints/api.py", "modules/oidc/oidc/endpoints/api.py"] + "relative", + ["modules/keycloak/keycloak/web/endpoints/api.py", "modules/oidc/oidc/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 diff --git a/tests/integration/test_pluggable_auth.py b/tests/integration/test_pluggable_auth.py index 834ae83ed..18e728570 100644 --- a/tests/integration/test_pluggable_auth.py +++ b/tests/integration/test_pluggable_auth.py @@ -46,8 +46,8 @@ def test_auth_provider_protocol_satisfied_by_users(): def test_auth_provider_protocol_satisfied_by_keycloak(): - from keycloak.provider import KeycloakAuthProvider - from keycloak.settings import KeycloakSettings + from keycloak.core.settings import KeycloakSettings + from keycloak.identity.provider import KeycloakAuthProvider settings = KeycloakSettings( server_url="https://example.com",