Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion docs/module-layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<package>.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. |
Expand Down Expand Up @@ -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 |
Expand Down
8 changes: 2 additions & 6 deletions host/tests/test_module_layout.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"models.py",
"models",
"tasks.py",
"task_config.py",
"contracts",
"pages",
"locales",
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion modules/auth/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
4 changes: 2 additions & 2 deletions modules/auth/tests/test_auth_oidc_authority.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion modules/auth/tests/test_auth_oidc_jwks_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down
2 changes: 1 addition & 1 deletion modules/auth/tests/test_auth_oidc_jwt_hardening.py
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down
2 changes: 1 addition & 1 deletion modules/auth/tests/test_auth_oidc_tokens.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions modules/oidc/oidc/core/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
"""What the OIDC provider builds on.

Constants, settings, presets, app state and the app-free runtime.
"""
File renamed without changes.
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
131 changes: 131 additions & 0 deletions modules/oidc/oidc/core/settings.py
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions modules/oidc/oidc/identity/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""The auth provider.

Discovery, OIDC client, token renewal and encryption, JWKS, the user directory
and its cache persistence.
"""
File renamed without changes.
File renamed without changes.
File renamed without changes.
5 changes: 5 additions & 0 deletions modules/oidc/oidc/identity/jwks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Provider entry point for the shared JWKS validator."""

from auth.oidc_common.jwks import JWKSCache

__all__ = ["JWKSCache"]
Loading
Loading