Skip to content
Open
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
2 changes: 1 addition & 1 deletion framework/core/tests/test_module_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ async def test_custom_fields(self):
async def test_frozen(self):
meta = ModuleMeta(name="Frozen")
with pytest.raises(AttributeError):
meta.name = "Changed" # type: ignore[misc] # ty: ignore[invalid-assignment]
meta.name = "Changed" # type: ignore[misc]


class DummyModule(ModuleBase):
Expand Down
4 changes: 2 additions & 2 deletions framework/core/tests/test_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,13 @@ async def test_services_is_frozen(self) -> None:
"""Mutation after construction must raise — singletons don't change at runtime."""
s = _make_services()
with pytest.raises((AttributeError, TypeError)):
s.settings = None # type: ignore[misc,assignment] # ty: ignore[invalid-assignment]
s.settings = None # type: ignore[misc,assignment]

async def test_services_has_slots(self) -> None:
"""Slotted dataclass prevents silent attribute additions (the original bloat pattern)."""
s = _make_services()
with pytest.raises((AttributeError, TypeError)):
s.rogue_new_attribute = 42 # type: ignore[attr-defined] # ty: ignore[invalid-assignment]
s.rogue_new_attribute = 42 # type: ignore[attr-defined]

async def test_services_round_trip_field_access(self) -> None:
"""Every declared field must be readable after construction."""
Expand Down
12 changes: 8 additions & 4 deletions framework/hosting/tests/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,12 +168,16 @@ async def test_app_state_has_sm_services(
(tmp_path / "host" / "templates").mkdir(parents=True)
(tmp_path / "host" / "templates" / "index.html").write_text("<html></html>")

# Exclude Keycloak — both ``users`` and ``keycloak`` are installed as
# entry points in the dev workspace. SM020 fires if both are present
# because the app is only meant to run with one auth provider.
# Keep a single auth provider — ``users``, ``keycloak``, and ``oidc`` are
# all installed as entry points in the dev workspace, and SM020 fires if
# more than one is active. The app is only meant to run with one provider.
from simple_module_core.discovery import discover_modules

all_names = [m.meta.name for m in discover_modules() if m.meta.name != "Keycloak"]
all_names = [
m.meta.name
for m in discover_modules()
if not (getattr(m, "_is_auth_provider", False) and m.meta.name != "Users")
]
app = create_app(Settings(modules_enabled=all_names))

sm = app.state.sm
Expand Down
2 changes: 1 addition & 1 deletion modules/keycloak/keycloak/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
Base = create_module_base("keycloak")


class KeycloakUserCache(Base, table=True):
class KeycloakUserCache(Base, table=True): # ty: ignore[unsupported-base]
__tablename__ = "keycloak_user_cache"

id: uuid_mod.UUID = Field(default_factory=uuid_mod.uuid4, primary_key=True)
Expand Down
96 changes: 96 additions & 0 deletions modules/oidc/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# simple_module_oidc

Generic **OpenID Connect** authentication provider for simple_module — with
**native Microsoft Entra ID (Azure AD)** support, plus Auth0, Okta, Zitadel,
Authentik, Keycloak, and any OIDC-compliant identity provider.

The module is configured from a provider's OIDC **discovery document**
(`.well-known/openid-configuration`), so endpoints (authorize / token / JWKS /
logout) and the issuer are resolved automatically — no per-provider URL
templating. Provider differences (claim names, scopes) are config; **presets**
fill those in for you.

It auto-registers as the application's auth provider. Browser users are
redirected to the IdP's hosted login; mobile/API clients send a bearer token
which is validated against the provider's JWKS.

## Install

Add to your app's `pyproject.toml` dependencies instead of another auth provider
(e.g. `simple_module_users` / `simple_module_keycloak` — only one auth provider
can be active):

```toml
dependencies = [
"simple_module_oidc==0.0.17",
]
```

Run `uv sync --all-packages` to install.

## Usage

Pick a provider preset and supply its credentials via environment variables (or
configure them through the settings admin UI after first boot). The module then
auto-registers as the application's auth provider — see the preset sections below.

## Native Microsoft Entra ID

Set the `entra` preset and your single-tenant registration details:

```bash
SM_OIDC_PROVIDER=entra
SM_OIDC_TENANT_ID=<your-tenant-guid> # or a verified domain
SM_OIDC_CLIENT_ID=<app-registration-client-id>
SM_OIDC_CLIENT_SECRET=<client-secret>
```

In the Entra app registration, add the redirect URI:

```
https://<your-host>/api/oidc/auth/callback
```

The `entra` preset derives the discovery URL
(`https://login.microsoftonline.com/{tenant_id}/v2.0/.well-known/openid-configuration`),
validates the **id_token**, keys the user cache on the stable **`oid`** claim, and
maps Entra **app roles** (the `roles` claim) to framework permissions via
`role_mapping` (default: `admin` and `user` map 1:1). Assign app roles in
*Entra → App registrations → App roles* and *Enterprise applications → Users and groups*.

## Any other OIDC provider

Use the `generic` preset with an explicit discovery URL:

```bash
SM_OIDC_PROVIDER=generic
SM_OIDC_DISCOVERY_URL=https://<issuer>/.well-known/openid-configuration
SM_OIDC_CLIENT_ID=<client-id>
SM_OIDC_CLIENT_SECRET=<client-secret>
```

## Configuration reference

| Env var | Default | Notes |
|---|---|---|
| `SM_OIDC_PROVIDER` | `generic` | Preset name (`entra`, `generic`). |
| `SM_OIDC_DISCOVERY_URL` | — | Explicit discovery URL; wins over the preset. |
| `SM_OIDC_TENANT_ID` | — | Used by templated presets (Entra) to derive the discovery URL. |
| `SM_OIDC_CLIENT_ID` | — | OAuth client id. |
| `SM_OIDC_CLIENT_SECRET` | — | OAuth client secret. |
| `SM_OIDC_AUDIENCE` | `client_id` | JWT audience to validate against. |
| `SM_OIDC_SCOPE` | preset | OAuth scope string. |
| `SM_OIDC_UID_CLAIM` | preset (`oid` for Entra, else `sub`) | Stable subject claim. |
| `SM_OIDC_USERNAME_CLAIM` | `preferred_username` | Display-name source. |
| `SM_OIDC_EMAIL_CLAIM` | `email` | Email claim. |
| `SM_OIDC_NAME_CLAIM` | `name` | Fallback display name. |
| `SM_OIDC_ROLES_CLAIM_PATH` | preset (`roles` for Entra) | Dotted path to a roles list (e.g. `realm_access.roles`). |

`login_redirect_url`, `jwks_cache_ttl_seconds`, and `role_mapping` are configurable
via the settings admin UI after first boot.

## Activation

Add `simple_module_oidc` to the host's `pyproject.toml` dependencies and
`[tool.uv.sources]`, remove the competing provider, then `uv sync --all-packages`.
Alternatively scope active modules with `SM_MODULES_ENABLED`.
1 change: 1 addition & 0 deletions modules/oidc/oidc/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Generic OIDC authentication provider for simple_module (Entra, Auth0, Okta, ...)."""
81 changes: 81 additions & 0 deletions modules/oidc/oidc/client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"""OIDC client -- authorization URL, code exchange, and logout.

Endpoint-driven: the authorize/token/end-session URLs come from the discovery
document, so this client is provider-agnostic.
"""

from __future__ import annotations

import secrets
from typing import Any
from urllib.parse import urlencode

import httpx


class OIDCClient:
"""Thin wrapper around a provider's discovered OIDC endpoints."""

def __init__(
self,
*,
authorization_endpoint: str,
token_endpoint: str,
end_session_endpoint: str,
client_id: str,
client_secret: str,
) -> None:
self._authorization_endpoint = authorization_endpoint
self._token_endpoint = token_endpoint
self._end_session_endpoint = end_session_endpoint
self._client_id = client_id
self._client_secret = client_secret

@property
def token_endpoint(self) -> str:
return self._token_endpoint

def build_authorization_url(
self,
redirect_uri: str,
nonce: str,
scope: str = "openid email profile",
) -> tuple[str, str]:
state = secrets.token_urlsafe(32)
params = {
"client_id": self._client_id,
"redirect_uri": redirect_uri,
"response_type": "code",
"scope": scope,
"state": state,
"nonce": nonce,
}
url = f"{self._authorization_endpoint}?{urlencode(params)}"
return url, state

async def exchange_code(self, code: str, redirect_uri: str) -> dict[str, Any]:
data = {
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
"client_id": self._client_id,
"client_secret": self._client_secret,
}
async with httpx.AsyncClient() as client:
resp = await client.post(self._token_endpoint, data=data, timeout=10)
resp.raise_for_status()
return resp.json()

def build_logout_url(
self,
post_logout_redirect_uri: str,
id_token_hint: str | None = None,
) -> str:
"""End-session URL. Falls back to the local redirect if the provider
does not advertise an ``end_session_endpoint``."""
if not self._end_session_endpoint:
return post_logout_redirect_uri
params: dict[str, str] = {"post_logout_redirect_uri": post_logout_redirect_uri}
if id_token_hint:
params["id_token_hint"] = id_token_hint
return f"{self._end_session_endpoint}?{urlencode(params)}"
1 change: 1 addition & 0 deletions modules/oidc/oidc/contracts/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""OIDC module contracts."""
49 changes: 49 additions & 0 deletions modules/oidc/oidc/discovery.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""OIDC discovery -- fetch and parse ``.well-known/openid-configuration``.

Resolved once at startup; the returned endpoints drive the OIDC client and the
JWKS validator, so per-provider URL templates are never hardcoded.
"""

from __future__ import annotations

from dataclasses import dataclass

import httpx


@dataclass(frozen=True)
class OidcMetadata:
"""The subset of OIDC provider metadata this module relies on."""

issuer: str
authorization_endpoint: str
token_endpoint: str
jwks_uri: str
end_session_endpoint: str = ""

@classmethod
def from_document(cls, doc: dict) -> OidcMetadata:
"""Build metadata from a parsed discovery document.

Raises ``ValueError`` if a required endpoint is absent.
"""
required = ("issuer", "authorization_endpoint", "token_endpoint", "jwks_uri")
missing = [k for k in required if not doc.get(k)]
if missing:
msg = f"OIDC discovery document missing required fields: {', '.join(missing)}"
raise ValueError(msg)
return cls(
issuer=doc["issuer"],
authorization_endpoint=doc["authorization_endpoint"],
token_endpoint=doc["token_endpoint"],
jwks_uri=doc["jwks_uri"],
end_session_endpoint=doc.get("end_session_endpoint", ""),
)


async def fetch_metadata(discovery_url: str) -> OidcMetadata:
"""Fetch and parse the provider's OIDC discovery document."""
async with httpx.AsyncClient() as client:
resp = await client.get(discovery_url, timeout=10)
resp.raise_for_status()
return OidcMetadata.from_document(resp.json())
1 change: 1 addition & 0 deletions modules/oidc/oidc/endpoints/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""OIDC endpoint routers."""
88 changes: 88 additions & 0 deletions modules/oidc/oidc/endpoints/api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"""OIDC API endpoints — login redirect, callback."""

from __future__ import annotations

import logging
import secrets

from fastapi import APIRouter, HTTPException, Request
from starlette.responses import RedirectResponse

logger = logging.getLogger(__name__)
router = APIRouter(prefix="/auth", tags=["oidc-auth"])

_SESSION_OIDC_STATE = "oidc_state"
_SESSION_OIDC_NONCE = "oidc_nonce"
_SESSION_USER_CTX = "user_ctx"
_SESSION_ID_TOKEN = "oidc_id_token"
_SESSION_NEXT = "next"


def _require_client(request: Request):
client = request.app.state.oidc.client
if client is None:
raise HTTPException(status_code=503, detail="OIDC provider not configured")
return client


@router.get("/login", name="oidc_auth_login")
async def oidc_login(request: Request):
client = _require_client(request)
s = request.app.state.oidc.settings
callback_url = str(request.url_for("oidc_auth_callback"))
nonce = secrets.token_urlsafe(32)
url, state = client.build_authorization_url(
redirect_uri=callback_url,
nonce=nonce,
scope=s.scope,
)
request.session[_SESSION_OIDC_STATE] = state
request.session[_SESSION_OIDC_NONCE] = nonce
return RedirectResponse(url, status_code=302)


@router.get("/callback", name="oidc_auth_callback")
async def oidc_callback(request: Request):
code = request.query_params.get("code")
state = request.query_params.get("state")

expected_state = request.session.pop(_SESSION_OIDC_STATE, None)
expected_nonce = request.session.pop(_SESSION_OIDC_NONCE, None)

if not code or not state or state != expected_state:
raise HTTPException(status_code=400, detail="Invalid OIDC state")

client = _require_client(request)
callback_url = str(request.url_for("oidc_auth_callback"))

try:
tokens = await client.exchange_code(code=code, redirect_uri=callback_url)
except Exception:
logger.exception("Token exchange failed")
raise HTTPException(status_code=502, detail="Token exchange failed") from None

id_token = tokens.get("id_token", "")

# Validate the id_token: aud == client_id across all OIDC providers, and the
# reliable identity source for Entra (its access tokens aren't app-validatable).
jwks_cache = request.app.state.oidc.jwks_cache
claims = await jwks_cache.validate_jwt(id_token) if jwks_cache else None
if claims is None:
raise HTTPException(status_code=401, detail="Token validation failed")

# Bind the id_token to this login: the nonce we sent in the authorize
# request must come back in the validated token (OIDC Core 3.1.3.7 §11).
# Without this an attacker could replay/inject a separately-obtained token.
if not expected_nonce or claims.get("nonce") != expected_nonce:
raise HTTPException(status_code=401, detail="OIDC nonce mismatch")

provider = request.app.state.auth.auth_provider
cache_id = await provider._upsert_user_cache(request, claims)
user_ctx = provider._claims_to_user_context(claims, cache_id=cache_id)

request.session[_SESSION_USER_CTX] = user_ctx.to_session_dict()
request.session[_SESSION_ID_TOKEN] = id_token

s = request.app.state.oidc.settings
next_url = request.session.pop(_SESSION_NEXT, None) or s.login_redirect_url
return RedirectResponse(next_url, status_code=303)
Loading
Loading