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 +1 -0
- oidc/client.py +138 -0
- oidc/constants.py +19 -0
- oidc/contracts/__init__.py +1 -0
- oidc/directory.py +10 -0
- oidc/discovery.py +103 -0
- oidc/endpoints/__init__.py +1 -0
- oidc/endpoints/api.py +119 -0
- oidc/endpoints/views.py +59 -0
- oidc/jwks.py +5 -0
- oidc/locales/de.json +14 -0
- oidc/locales/en.json +14 -0
- oidc/models.py +28 -0
- oidc/module.py +87 -0
- oidc/package.json +7 -0
- oidc/pages/LoggedOut.tsx +15 -0
- oidc/pages/Login.tsx +20 -0
- oidc/persistence.py +28 -0
- oidc/presets.py +59 -0
- oidc/provider.py +172 -0
- oidc/py.typed +0 -0
- oidc/renewal.py +85 -0
- oidc/runtime.py +74 -0
- oidc/settings.py +131 -0
- oidc/state.py +25 -0
- oidc/tokens.py +10 -0
- simple_module_oidc-0.0.43.dist-info/METADATA +231 -0
- simple_module_oidc-0.0.43.dist-info/RECORD +30 -0
- simple_module_oidc-0.0.43.dist-info/WHEEL +4 -0
- simple_module_oidc-0.0.43.dist-info/entry_points.txt +2 -0
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)
|
oidc/endpoints/views.py
ADDED
|
@@ -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
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
oidc/pages/LoggedOut.tsx
ADDED
|
@@ -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
|
+
}
|