simple-module-oidc 0.0.43__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
oidc/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Generic OIDC authentication provider for simple_module (Entra, Auth0, Okta, ...)."""
oidc/client.py ADDED
@@ -0,0 +1,138 @@
1
+ """OIDC client -- authorization URL, code exchange, and logout.
2
+
3
+ Endpoint-driven: the authorize/token/end-session URLs come from the discovery
4
+ document, so this client is provider-agnostic.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import secrets
11
+ from typing import Any
12
+ from urllib.parse import quote_plus, urlencode
13
+
14
+ import httpx
15
+ from auth.oidc_common.session import pkce_challenge
16
+
17
+ # Revocation runs inside the logout request: a hung IdP must not hold it long.
18
+ _REVOKE_TIMEOUT_SECONDS = 3
19
+
20
+
21
+ class OIDCClient:
22
+ """Thin wrapper around a provider's discovered OIDC endpoints."""
23
+
24
+ def __init__(
25
+ self,
26
+ *,
27
+ authorization_endpoint: str,
28
+ token_endpoint: str,
29
+ end_session_endpoint: str,
30
+ client_id: str,
31
+ client_secret: str,
32
+ revocation_endpoint: str = "",
33
+ token_endpoint_auth_methods_supported: list[str] | None = None,
34
+ revocation_endpoint_auth_methods_supported: list[str] | None = None,
35
+ ) -> None:
36
+ self._authorization_endpoint = authorization_endpoint
37
+ self._token_endpoint = token_endpoint
38
+ self._end_session_endpoint = end_session_endpoint
39
+ self._client_id = client_id
40
+ self._client_secret = client_secret
41
+ self._revocation_endpoint = revocation_endpoint
42
+ self._token_endpoint_auth_methods_supported = token_endpoint_auth_methods_supported
43
+ # The revocation endpoint advertises its own methods; fall back to the token endpoint's.
44
+ self._revocation_endpoint_auth_methods_supported = (
45
+ revocation_endpoint_auth_methods_supported
46
+ if revocation_endpoint_auth_methods_supported is not None
47
+ else token_endpoint_auth_methods_supported
48
+ )
49
+
50
+ def build_authorization_url(
51
+ self,
52
+ redirect_uri: str,
53
+ nonce: str,
54
+ scope: str = "openid email profile",
55
+ *,
56
+ code_verifier: str,
57
+ ) -> tuple[str, str]:
58
+ state = secrets.token_urlsafe(32)
59
+ params = {
60
+ "client_id": self._client_id,
61
+ "redirect_uri": redirect_uri,
62
+ "response_type": "code",
63
+ "scope": scope,
64
+ "state": state,
65
+ "nonce": nonce,
66
+ "code_challenge_method": "S256",
67
+ "code_challenge": pkce_challenge(code_verifier),
68
+ }
69
+ separator = "&" if "?" in self._authorization_endpoint else "?"
70
+ url = f"{self._authorization_endpoint}{separator}{urlencode(params)}"
71
+ return url, state
72
+
73
+ async def exchange_code(
74
+ self, code: str, redirect_uri: str, *, code_verifier: str
75
+ ) -> dict[str, Any]:
76
+ return await self._token_request(
77
+ grant_type="authorization_code",
78
+ code=code,
79
+ code_verifier=code_verifier,
80
+ redirect_uri=redirect_uri,
81
+ )
82
+
83
+ def build_logout_url(
84
+ self,
85
+ post_logout_redirect_uri: str,
86
+ ) -> str:
87
+ """End-session URL. Falls back to the local redirect if the provider
88
+ does not advertise an ``end_session_endpoint``."""
89
+ if not self._end_session_endpoint:
90
+ return post_logout_redirect_uri
91
+ params = {
92
+ "post_logout_redirect_uri": post_logout_redirect_uri,
93
+ "client_id": self._client_id,
94
+ }
95
+ separator = "&" if "?" in self._end_session_endpoint else "?"
96
+ return f"{self._end_session_endpoint}{separator}{urlencode(params)}"
97
+
98
+ async def refresh_tokens(self, refresh_token: str) -> dict[str, Any]:
99
+ return await self._token_request(grant_type="refresh_token", refresh_token=refresh_token)
100
+
101
+ async def revoke_refresh_token(self, refresh_token: str) -> None:
102
+ if self._revocation_endpoint:
103
+ # httpx timeouts are per phase; bound the whole exchange too.
104
+ async with asyncio.timeout(_REVOKE_TIMEOUT_SECONDS):
105
+ await self._post(
106
+ self._revocation_endpoint,
107
+ self._revocation_endpoint_auth_methods_supported,
108
+ {"token": refresh_token, "token_type_hint": "refresh_token"},
109
+ timeout=_REVOKE_TIMEOUT_SECONDS,
110
+ )
111
+
112
+ async def _token_request(self, **grant: str) -> dict[str, Any]:
113
+ resp = await self._post(
114
+ self._token_endpoint, self._token_endpoint_auth_methods_supported, dict(grant)
115
+ )
116
+ return resp.json()
117
+
118
+ async def _post(
119
+ self,
120
+ endpoint: str,
121
+ methods: list[str] | None,
122
+ fields: dict[str, str],
123
+ *,
124
+ timeout: float = 10,
125
+ ) -> httpx.Response:
126
+ data = dict(fields)
127
+ extra: dict[str, Any] = {}
128
+ if methods is None or "client_secret_post" in methods:
129
+ data.update(client_id=self._client_id, client_secret=self._client_secret)
130
+ else:
131
+ # RFC 6749 requires form-encoding each credential before HTTP Basic encoding.
132
+ extra["auth"] = httpx.BasicAuth(
133
+ quote_plus(self._client_id), quote_plus(self._client_secret)
134
+ )
135
+ async with httpx.AsyncClient() as client:
136
+ resp = await client.post(endpoint, data=data, timeout=timeout, **extra)
137
+ resp.raise_for_status()
138
+ return resp
oidc/constants.py ADDED
@@ -0,0 +1,19 @@
1
+ """OIDC routes, page names and session keys."""
2
+
3
+ _MODULE_AUTH = "Auth"
4
+ _MODULE_SETTINGS = "Settings"
5
+ _PAGE_LOGIN = "Oidc/Login"
6
+ _PAGE_LOGGED_OUT = "Oidc/LoggedOut"
7
+ LOGIN_PATH = "/oidc/login"
8
+ LOGOUT_PATH = "/oidc/logout"
9
+ LOGGED_OUT_PATH = "/oidc/logged-out"
10
+ AUTH_LOGIN_PATH = "/api/oidc/auth/login"
11
+ AUTH_CALLBACK_PATH = "/api/oidc/auth/callback"
12
+ SESSION_STATE_KEY = "oidc_state"
13
+ SESSION_NONCE_KEY = "oidc_nonce"
14
+ SESSION_VERIFIER_KEY = "oidc_pkce_verifier"
15
+ SESSION_USER_KEY = "user_ctx"
16
+ SESSION_IDENTITY_KEY = "oidc_identity"
17
+ SESSION_REFRESH_TOKEN_KEY = "oidc_refresh_token"
18
+ SESSION_REFRESH_RETRY_AT_KEY = "oidc_refresh_retry_at"
19
+ DEFAULT_LOGIN_REDIRECT_URL = "/dashboard/"
@@ -0,0 +1 @@
1
+ """OIDC module contracts."""
oidc/directory.py ADDED
@@ -0,0 +1,10 @@
1
+ """User directory over the provider's unchanged cache table."""
2
+
3
+ from auth.oidc_common.directory import CacheDirectory
4
+
5
+ from oidc.models import OidcUserCache
6
+
7
+
8
+ class OidcDirectory(CacheDirectory):
9
+ def __init__(self) -> None:
10
+ super().__init__(OidcUserCache)
oidc/discovery.py ADDED
@@ -0,0 +1,103 @@
1
+ """Validated OIDC discovery metadata; endpoint hosts may legitimately differ."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+ from urllib.parse import urlsplit
9
+
10
+ import httpx
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ def _validate_url(value: object, *, development: bool) -> str:
16
+ if not isinstance(value, str) or not value or any(c.isspace() for c in value):
17
+ raise ValueError("OIDC metadata URL must be a non-empty URL")
18
+ parsed = urlsplit(value)
19
+ if (
20
+ parsed.scheme not in ({"https", "http"} if development else {"https"})
21
+ or not parsed.hostname
22
+ or parsed.username is not None
23
+ or parsed.password is not None
24
+ or parsed.fragment
25
+ ):
26
+ raise ValueError("OIDC metadata requires HTTPS URLs without credentials or fragments")
27
+ return value
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class OidcMetadata:
32
+ issuer: str
33
+ authorization_endpoint: str
34
+ token_endpoint: str
35
+ jwks_uri: str
36
+ end_session_endpoint: str = ""
37
+ revocation_endpoint: str = ""
38
+ token_endpoint_auth_methods_supported: list[str] | None = None
39
+ revocation_endpoint_auth_methods_supported: list[str] | None = None
40
+
41
+ @classmethod
42
+ def from_document(
43
+ cls, doc: dict, *, expected_issuer: str | None = None, development: bool = False
44
+ ) -> OidcMetadata:
45
+ if not isinstance(doc, dict):
46
+ raise ValueError("OIDC discovery document must be an object")
47
+ required = ("issuer", "authorization_endpoint", "token_endpoint", "jwks_uri")
48
+ missing = [key for key in required if not doc.get(key)]
49
+ if missing:
50
+ raise ValueError(
51
+ f"OIDC discovery document missing required fields: {', '.join(missing)}"
52
+ )
53
+ values: dict[str, Any] = {
54
+ key: _validate_url(doc[key], development=development) for key in required
55
+ }
56
+ issuer = urlsplit(values["issuer"])
57
+ if issuer.query or "{" in values["issuer"] or "}" in values["issuer"]:
58
+ raise ValueError("OIDC issuer must be concrete and have no query")
59
+ # Compared without one trailing slash: issuers such as Auth0's end in
60
+ # "/" while the discovery URL is built without it. Tokens are still
61
+ # validated against the issuer exactly as the document states it.
62
+ if expected_issuer is not None and values["issuer"].rstrip("/") != expected_issuer.rstrip(
63
+ "/"
64
+ ):
65
+ raise ValueError("OIDC discovery issuer does not match configured issuer")
66
+ logout = doc.get("end_session_endpoint", "")
67
+ values["end_session_endpoint"] = (
68
+ _validate_url(logout, development=development) if logout != "" else ""
69
+ )
70
+ # Optional extras must not take sign-in down: a malformed one is ignored
71
+ # (no revocation; the default client authentication) with a warning.
72
+ values["revocation_endpoint"] = ""
73
+ if doc.get("revocation_endpoint", "") != "":
74
+ try:
75
+ values["revocation_endpoint"] = _validate_url(
76
+ doc["revocation_endpoint"], development=development
77
+ )
78
+ except ValueError:
79
+ logger.warning("Ignoring unusable OIDC revocation_endpoint in discovery")
80
+ for key in (
81
+ "token_endpoint_auth_methods_supported",
82
+ "revocation_endpoint_auth_methods_supported",
83
+ ):
84
+ methods = doc.get(key)
85
+ if methods is not None and (
86
+ not isinstance(methods, list) or not all(isinstance(m, str) for m in methods)
87
+ ):
88
+ logger.warning("Ignoring malformed OIDC %s in discovery", key)
89
+ methods = None
90
+ values[key] = methods
91
+ return cls(**values)
92
+
93
+
94
+ async def fetch_metadata(
95
+ discovery_url: str, *, expected_issuer: str | None = None, development: bool = False
96
+ ) -> OidcMetadata:
97
+ _validate_url(discovery_url, development=development)
98
+ async with httpx.AsyncClient() as client:
99
+ resp = await client.get(discovery_url, timeout=10)
100
+ resp.raise_for_status()
101
+ return OidcMetadata.from_document(
102
+ resp.json(), expected_issuer=expected_issuer, development=development
103
+ )
@@ -0,0 +1 @@
1
+ """OIDC endpoint routers."""
oidc/endpoints/api.py ADDED
@@ -0,0 +1,119 @@
1
+ """OIDC login transaction: PKCE, nonce, session rotation and bounded authority."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import secrets
7
+ import time
8
+
9
+ from auth.oidc_common.session import sign_in, stale_callback
10
+ from fastapi import APIRouter, HTTPException, Request
11
+ from starlette.responses import RedirectResponse
12
+
13
+ from oidc.constants import (
14
+ SESSION_IDENTITY_KEY,
15
+ SESSION_NONCE_KEY,
16
+ SESSION_STATE_KEY,
17
+ SESSION_USER_KEY,
18
+ SESSION_VERIFIER_KEY,
19
+ )
20
+ from oidc.runtime import require_client
21
+ from oidc.tokens import store_refresh_token
22
+
23
+ logger = logging.getLogger(__name__)
24
+ router = APIRouter(prefix="/auth", tags=["oidc-auth"])
25
+
26
+
27
+ @router.get("/login", name="oidc_auth_login")
28
+ async def oidc_login(request: Request):
29
+ client = await require_client(request)
30
+ nonce = secrets.token_urlsafe(32)
31
+ verifier = secrets.token_urlsafe(64)
32
+ url, state = client.build_authorization_url(
33
+ redirect_uri=str(request.url_for("oidc_auth_callback")),
34
+ nonce=nonce,
35
+ scope=request.app.state.oidc.settings.scope,
36
+ code_verifier=verifier,
37
+ )
38
+ request.session[SESSION_STATE_KEY] = state
39
+ request.session[SESSION_NONCE_KEY] = nonce
40
+ request.session[SESSION_VERIFIER_KEY] = verifier
41
+ return RedirectResponse(url, status_code=302)
42
+
43
+
44
+ @router.get("/callback", name="oidc_auth_callback")
45
+ async def oidc_callback(request: Request):
46
+ code = request.query_params.get("code")
47
+ # The state is compared before the login transaction is consumed: a reply
48
+ # that does not carry it (a forged link) leaves the pending sign-in alone.
49
+ stale = stale_callback(
50
+ request,
51
+ SESSION_STATE_KEY,
52
+ SESSION_USER_KEY,
53
+ request.app.state.oidc.settings.login_redirect_url,
54
+ )
55
+ if stale is not None:
56
+ return stale
57
+ request.session.pop(SESSION_STATE_KEY, None)
58
+ expected_nonce = request.session.pop(SESSION_NONCE_KEY, None)
59
+ verifier = request.session.pop(SESSION_VERIFIER_KEY, None)
60
+ # Past this point the reply belongs to this login, and every outcome,
61
+ # including an ``error`` reply (access_denied, login_required, ...), spends it.
62
+ if request.query_params.get("error"):
63
+ raise HTTPException(status_code=401, detail="Sign-in was refused by the identity provider")
64
+ if not code:
65
+ raise HTTPException(status_code=400, detail="Invalid OIDC state")
66
+ if not isinstance(expected_nonce, str) or not expected_nonce:
67
+ raise HTTPException(status_code=400, detail="Missing OIDC nonce")
68
+ if not isinstance(verifier, str) or not verifier:
69
+ raise HTTPException(status_code=400, detail="Missing PKCE verifier")
70
+ client = await require_client(request)
71
+ try:
72
+ tokens = await client.exchange_code(
73
+ code=code,
74
+ redirect_uri=str(request.url_for("oidc_auth_callback")),
75
+ code_verifier=verifier,
76
+ )
77
+ except Exception:
78
+ logger.warning("OIDC token exchange failed")
79
+ raise HTTPException(status_code=502, detail="Token exchange failed") from None
80
+ if not isinstance(tokens, dict):
81
+ logger.warning("OIDC token endpoint returned a non-object response")
82
+ raise HTTPException(status_code=502, detail="Token exchange failed")
83
+ s = request.app.state.oidc.settings
84
+ cache = request.app.state.oidc.jwks_cache
85
+ claims = (
86
+ await cache.validate_jwt(tokens.get("id_token", ""), audience=s.client_id)
87
+ if cache
88
+ else None
89
+ )
90
+ nonce = claims.get("nonce") if claims else None
91
+ if not isinstance(nonce, str) or not secrets.compare_digest(
92
+ nonce.encode(), expected_nonce.encode()
93
+ ):
94
+ raise HTTPException(status_code=401, detail="ID token validation failed")
95
+ expires_at = claims.get("exp")
96
+ if type(expires_at) is not int or expires_at <= time.time():
97
+ raise HTTPException(status_code=401, detail="Invalid ID token expiry")
98
+ provider = request.app.state.auth.auth_provider
99
+ try:
100
+ identity = provider._identity(claims)
101
+ except ValueError:
102
+ raise HTTPException(status_code=401, detail="Missing OIDC identity") from None
103
+ cache_id = await provider._upsert_user_cache(request, claims)
104
+ if cache_id is None:
105
+ raise HTTPException(status_code=503, detail="OIDC identity unavailable")
106
+ user_ctx = provider._claims_to_user_context(claims, cache_id=cache_id)
107
+ next_url = sign_in(
108
+ request,
109
+ user=user_ctx,
110
+ expires_at=expires_at,
111
+ authority=provider._session_authority,
112
+ redirect=s.login_redirect_url,
113
+ user_key=SESSION_USER_KEY,
114
+ identity_key=SESSION_IDENTITY_KEY,
115
+ identity=identity,
116
+ )
117
+ if tokens.get("refresh_token"):
118
+ store_refresh_token(request.app, request.session, tokens["refresh_token"])
119
+ return RedirectResponse(next_url, status_code=303)
@@ -0,0 +1,59 @@
1
+ """OIDC interstitials and RP-initiated logout."""
2
+
3
+ import logging
4
+
5
+ from auth.oidc_common.renewal_outcomes import forget_outcomes
6
+ from fastapi import APIRouter, Request
7
+ from simple_module_hosting.inertia_deps import InertiaDep, is_inertia_request
8
+ from simple_module_inertia.response import location
9
+ from starlette.responses import RedirectResponse
10
+
11
+ from oidc.constants import _PAGE_LOGGED_OUT, _PAGE_LOGIN, LOGGED_OUT_PATH, SESSION_REFRESH_TOKEN_KEY
12
+ from oidc.renewal import _KEYS
13
+ from oidc.runtime import is_signed_in
14
+ from oidc.tokens import open_refresh_token
15
+
16
+ logger = logging.getLogger(__name__)
17
+ router = APIRouter(tags=["oidc-views"])
18
+
19
+
20
+ @router.get("/login")
21
+ async def login_page(inertia: InertiaDep):
22
+ return await inertia.render(_PAGE_LOGIN)
23
+
24
+
25
+ @router.get("/logged-out")
26
+ async def logged_out_page(request: Request, inertia: InertiaDep):
27
+ # Only meaningful once the session is gone; a signed-in visitor is not
28
+ # told they were signed out.
29
+ if is_signed_in(request.session):
30
+ return RedirectResponse(request.app.state.oidc.settings.login_redirect_url, 303)
31
+ return await inertia.render(_PAGE_LOGGED_OUT)
32
+
33
+
34
+ @router.post("/logout")
35
+ async def logout(request: Request):
36
+ client = request.app.state.oidc.client
37
+ if client is not None:
38
+ try:
39
+ refresh_token = open_refresh_token(
40
+ request.app, request.session.get(SESSION_REFRESH_TOKEN_KEY)
41
+ )
42
+ if refresh_token:
43
+ await client.revoke_refresh_token(refresh_token)
44
+ except Exception as exc:
45
+ # The class (timeout, HTTP status error, ...), never the token or body.
46
+ logger.warning(
47
+ "OIDC refresh token revocation failed (%s); continuing logout", type(exc).__name__
48
+ )
49
+ await forget_outcomes(request.app, _KEYS, request.session)
50
+ request.session.clear()
51
+ destination = f"{str(request.base_url).rstrip('/')}{LOGGED_OUT_PATH}"
52
+ if client is not None:
53
+ try:
54
+ destination = client.build_logout_url(post_logout_redirect_uri=destination)
55
+ except Exception:
56
+ logger.warning("OIDC logout unavailable; local session cleared")
57
+ if is_inertia_request(request.scope):
58
+ return location(destination)
59
+ return RedirectResponse(destination, status_code=303)
oidc/jwks.py ADDED
@@ -0,0 +1,5 @@
1
+ """Provider entry point for the shared JWKS validator."""
2
+
3
+ from auth.oidc_common.jwks import JWKSCache
4
+
5
+ __all__ = ["JWKSCache"]
oidc/locales/de.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "login": {
3
+ "redirecting": "Weiterleitung zum Identitätsanbieter…",
4
+ "manual": "Keine Weiterleitung? Manuell fortfahren"
5
+ },
6
+ "logout": {
7
+ "title": "Abgemeldet",
8
+ "message": "Sie wurden erfolgreich von dieser App abgemeldet.",
9
+ "sign_in_again": "Erneut anmelden"
10
+ },
11
+ "nav": {
12
+ "logout": "Abmelden"
13
+ }
14
+ }
oidc/locales/en.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "login": {
3
+ "redirecting": "Redirecting to identity provider\u2026",
4
+ "manual": "Not redirected? Continue manually"
5
+ },
6
+ "logout": {
7
+ "title": "Signed Out",
8
+ "message": "You have been signed out successfully.",
9
+ "sign_in_again": "Sign in again"
10
+ },
11
+ "nav": {
12
+ "logout": "Logout"
13
+ }
14
+ }
oidc/models.py ADDED
@@ -0,0 +1,28 @@
1
+ """OIDC user cache -- maps a provider subject to a stable framework UUID."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid as uuid_mod
6
+ from datetime import datetime
7
+
8
+ from simple_module_db.base import create_module_base
9
+ from simple_module_db.types import AwareDateTime
10
+ from sqlalchemy import UniqueConstraint
11
+ from sqlmodel import Field
12
+
13
+ Base = create_module_base("oidc")
14
+
15
+
16
+ class OidcUserCache(Base, table=True): # ty: ignore[unsupported-base]
17
+ __tablename__ = "oidc_user_cache"
18
+
19
+ id: uuid_mod.UUID = Field(default_factory=uuid_mod.uuid4, primary_key=True)
20
+ __table_args__ = (
21
+ UniqueConstraint("issuer", "subject", name="uq_oidc_user_cache_issuer_subject"),
22
+ )
23
+
24
+ issuer: str
25
+ subject: str
26
+ email: str = ""
27
+ full_name: str | None = None
28
+ last_login_at: datetime | None = Field(default=None, sa_type=AwareDateTime())
oidc/module.py ADDED
@@ -0,0 +1,87 @@
1
+ """Generic OIDC authentication module (Entra, Auth0, Okta, ...)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.resources
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING
8
+
9
+ from simple_module_core.menu import MenuItem, MenuRegistry, MenuSection
10
+ from simple_module_core.module import ModuleBase, ModuleMeta
11
+ from simple_module_core.module_settings import register_module_settings
12
+ from simple_module_core.user_directory import set_user_directory
13
+
14
+ from oidc.constants import (
15
+ _MODULE_AUTH,
16
+ _MODULE_SETTINGS,
17
+ AUTH_CALLBACK_PATH,
18
+ AUTH_LOGIN_PATH,
19
+ LOGGED_OUT_PATH,
20
+ LOGIN_PATH,
21
+ LOGOUT_PATH,
22
+ )
23
+
24
+ if TYPE_CHECKING:
25
+ from fastapi import APIRouter, FastAPI
26
+
27
+
28
+ class OidcModule(ModuleBase):
29
+ meta = ModuleMeta(
30
+ name="Oidc",
31
+ route_prefix="/api/oidc",
32
+ view_prefix="/oidc",
33
+ depends_on=[_MODULE_AUTH],
34
+ optional_depends_on=[_MODULE_SETTINGS],
35
+ )
36
+ _is_auth_provider = True
37
+
38
+ def register_settings(self, app: FastAPI) -> None:
39
+ from oidc.directory import OidcDirectory
40
+ from oidc.provider import OidcAuthProvider
41
+ from oidc.settings import OidcSettings
42
+ from oidc.state import OidcState
43
+
44
+ register_module_settings(
45
+ app,
46
+ "oidc",
47
+ OidcSettings,
48
+ lambda s: OidcState(settings=s),
49
+ )
50
+
51
+ set_user_directory(app, OidcDirectory())
52
+ app.state.auth.auth_provider = OidcAuthProvider(
53
+ app.state.oidc.settings, state=app.state.oidc
54
+ )
55
+
56
+ def register_menu_items(self, registry: MenuRegistry) -> None:
57
+ registry.add(
58
+ MenuItem(
59
+ label="Logout",
60
+ label_key="oidc.nav.logout",
61
+ url=LOGOUT_PATH,
62
+ icon="log-out",
63
+ order=999,
64
+ section=MenuSection.USER_DROPDOWN,
65
+ method="post",
66
+ )
67
+ )
68
+
69
+ def register_routes(self, api_router: APIRouter, view_router: APIRouter) -> None:
70
+ from oidc.endpoints.api import router as api
71
+ from oidc.endpoints.views import router as views
72
+
73
+ api_router.include_router(api)
74
+ view_router.include_router(views)
75
+
76
+ def register_public_routes(self, registry) -> None:
77
+ for path in (LOGIN_PATH, LOGGED_OUT_PATH, AUTH_LOGIN_PATH, AUTH_CALLBACK_PATH):
78
+ registry.add_exact(path, methods={"GET"})
79
+ registry.add_exact(LOGOUT_PATH, methods={"POST"})
80
+
81
+ async def on_startup(self, app: FastAPI) -> None:
82
+ from oidc.runtime import initialize
83
+
84
+ await initialize(app)
85
+
86
+ def locale_dirs(self) -> dict[str, Path]:
87
+ return {"oidc": Path(str(importlib.resources.files(__package__) / "locales"))}
oidc/package.json ADDED
@@ -0,0 +1,7 @@
1
+ {
2
+ "name": "@simple-module/oidc",
3
+ "private": true,
4
+ "version": "0.0.43",
5
+ "type": "module",
6
+ "dependencies": {}
7
+ }
@@ -0,0 +1,15 @@
1
+ import { Link } from '@inertiajs/react';
2
+ import { keys, useT } from '@simple-module-py/i18n';
3
+
4
+ export default function LoggedOut() {
5
+ const { t } = useT();
6
+ return (
7
+ <div className="flex min-h-screen flex-col items-center justify-center gap-4">
8
+ <h1 className="text-2xl font-bold">{t(keys.oidc.logout.title)}</h1>
9
+ <p className="text-muted-foreground">{t(keys.oidc.logout.message)}</p>
10
+ <Link href="/oidc/login" className="text-primary underline">
11
+ {t(keys.oidc.logout.sign_in_again)}
12
+ </Link>
13
+ </div>
14
+ );
15
+ }
oidc/pages/Login.tsx ADDED
@@ -0,0 +1,20 @@
1
+ import { keys, useT } from '@simple-module-py/i18n';
2
+ import { useEffect } from 'react';
3
+
4
+ const START_LOGIN_URL = '/api/oidc/auth/login';
5
+
6
+ export default function Login() {
7
+ const { t } = useT();
8
+ useEffect(() => {
9
+ window.location.assign(START_LOGIN_URL);
10
+ }, []);
11
+
12
+ return (
13
+ <div className="flex min-h-screen flex-col items-center justify-center gap-4">
14
+ <p className="text-muted-foreground">{t(keys.oidc.login.redirecting)}</p>
15
+ <a href={START_LOGIN_URL} className="text-primary underline">
16
+ {t(keys.oidc.login.manual)}
17
+ </a>
18
+ </div>
19
+ );
20
+ }