simple-module-oidc 0.0.43__tar.gz
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.
- simple_module_oidc-0.0.43/.gitignore +69 -0
- simple_module_oidc-0.0.43/PKG-INFO +231 -0
- simple_module_oidc-0.0.43/README.md +202 -0
- simple_module_oidc-0.0.43/oidc/__init__.py +1 -0
- simple_module_oidc-0.0.43/oidc/client.py +138 -0
- simple_module_oidc-0.0.43/oidc/constants.py +19 -0
- simple_module_oidc-0.0.43/oidc/contracts/__init__.py +1 -0
- simple_module_oidc-0.0.43/oidc/directory.py +10 -0
- simple_module_oidc-0.0.43/oidc/discovery.py +103 -0
- simple_module_oidc-0.0.43/oidc/endpoints/__init__.py +1 -0
- simple_module_oidc-0.0.43/oidc/endpoints/api.py +119 -0
- simple_module_oidc-0.0.43/oidc/endpoints/views.py +59 -0
- simple_module_oidc-0.0.43/oidc/jwks.py +5 -0
- simple_module_oidc-0.0.43/oidc/locales/de.json +14 -0
- simple_module_oidc-0.0.43/oidc/locales/en.json +14 -0
- simple_module_oidc-0.0.43/oidc/models.py +28 -0
- simple_module_oidc-0.0.43/oidc/module.py +87 -0
- simple_module_oidc-0.0.43/oidc/pages/LoggedOut.tsx +15 -0
- simple_module_oidc-0.0.43/oidc/pages/Login.tsx +20 -0
- simple_module_oidc-0.0.43/oidc/persistence.py +28 -0
- simple_module_oidc-0.0.43/oidc/presets.py +59 -0
- simple_module_oidc-0.0.43/oidc/provider.py +172 -0
- simple_module_oidc-0.0.43/oidc/py.typed +0 -0
- simple_module_oidc-0.0.43/oidc/renewal.py +85 -0
- simple_module_oidc-0.0.43/oidc/runtime.py +74 -0
- simple_module_oidc-0.0.43/oidc/settings.py +131 -0
- simple_module_oidc-0.0.43/oidc/state.py +25 -0
- simple_module_oidc-0.0.43/oidc/tokens.py +10 -0
- simple_module_oidc-0.0.43/package.json +7 -0
- simple_module_oidc-0.0.43/pyproject.toml +53 -0
- simple_module_oidc-0.0.43/tests/conftest.py +120 -0
- simple_module_oidc-0.0.43/tests/oidc_flow_helpers.py +44 -0
- simple_module_oidc-0.0.43/tests/test_oidc_authorization.py +116 -0
- simple_module_oidc-0.0.43/tests/test_oidc_authorization_query.py +41 -0
- simple_module_oidc-0.0.43/tests/test_oidc_bearer_policy.py +49 -0
- simple_module_oidc-0.0.43/tests/test_oidc_boot.py +68 -0
- simple_module_oidc-0.0.43/tests/test_oidc_cache_persistence.py +168 -0
- simple_module_oidc-0.0.43/tests/test_oidc_client.py +119 -0
- simple_module_oidc-0.0.43/tests/test_oidc_directory.py +76 -0
- simple_module_oidc-0.0.43/tests/test_oidc_discovery.py +105 -0
- simple_module_oidc-0.0.43/tests/test_oidc_endpoint_auth.py +216 -0
- simple_module_oidc-0.0.43/tests/test_oidc_flow.py +133 -0
- simple_module_oidc-0.0.43/tests/test_oidc_jwks.py +116 -0
- simple_module_oidc-0.0.43/tests/test_oidc_jwks_resilience.py +189 -0
- simple_module_oidc-0.0.43/tests/test_oidc_jwt_policy.py +124 -0
- simple_module_oidc-0.0.43/tests/test_oidc_keycloak_flow.py +116 -0
- simple_module_oidc-0.0.43/tests/test_oidc_legacy_authority.py +111 -0
- simple_module_oidc-0.0.43/tests/test_oidc_logout.py +96 -0
- simple_module_oidc-0.0.43/tests/test_oidc_migration.py +66 -0
- simple_module_oidc-0.0.43/tests/test_oidc_module.py +39 -0
- simple_module_oidc-0.0.43/tests/test_oidc_provider.py +131 -0
- simple_module_oidc-0.0.43/tests/test_oidc_refresh.py +249 -0
- simple_module_oidc-0.0.43/tests/test_oidc_session_transaction.py +222 -0
- simple_module_oidc-0.0.43/tests/test_oidc_settings_presets.py +119 -0
- simple_module_oidc-0.0.43/tests/test_oidc_settings_runtime.py +34 -0
- simple_module_oidc-0.0.43/tests/test_oidc_tenant_resolver.py +51 -0
- simple_module_oidc-0.0.43/tests-js/oidc-login.test.tsx +25 -0
- simple_module_oidc-0.0.43/tests-js/oidc-translations.test.tsx +45 -0
- simple_module_oidc-0.0.43/tsconfig.json +4 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
dist/
|
|
6
|
+
build/
|
|
7
|
+
.venv/
|
|
8
|
+
*.egg
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# Node
|
|
12
|
+
node_modules/
|
|
13
|
+
|
|
14
|
+
# IDE
|
|
15
|
+
.idea/
|
|
16
|
+
.vscode/
|
|
17
|
+
*.swp
|
|
18
|
+
*.swo
|
|
19
|
+
|
|
20
|
+
# Environment
|
|
21
|
+
.env
|
|
22
|
+
|
|
23
|
+
# Database
|
|
24
|
+
*.db
|
|
25
|
+
*.db-wal
|
|
26
|
+
*.db-shm
|
|
27
|
+
*.db-journal
|
|
28
|
+
*.sqlite3
|
|
29
|
+
|
|
30
|
+
# Module-managed runtime state (e.g. uploaded dataset files,
|
|
31
|
+
# default storage_dir for SM_DATASETS_STORAGE_DIR).
|
|
32
|
+
var/
|
|
33
|
+
|
|
34
|
+
# file_storage filesystem backend default root (override via SM_FILE_STORAGE_FS_ROOT_PATH).
|
|
35
|
+
uploads/
|
|
36
|
+
|
|
37
|
+
# Vite
|
|
38
|
+
host/static/dist/
|
|
39
|
+
|
|
40
|
+
# VitePress (docs)
|
|
41
|
+
docs/.vitepress/cache/
|
|
42
|
+
docs/.vitepress/dist/
|
|
43
|
+
|
|
44
|
+
# Auto-generated frontend module manifest (regenerated by the host at boot
|
|
45
|
+
# or via `make gen-pages`).
|
|
46
|
+
host/client_app/modules.manifest.json
|
|
47
|
+
host/client_app/modules.generated.ts
|
|
48
|
+
host/client_app/modules.generated.css
|
|
49
|
+
host/client_app/modules.assets.json
|
|
50
|
+
|
|
51
|
+
# Worktrees
|
|
52
|
+
.worktrees/
|
|
53
|
+
|
|
54
|
+
# Performance profiles
|
|
55
|
+
.memray/
|
|
56
|
+
.benchmarks/
|
|
57
|
+
|
|
58
|
+
# OS
|
|
59
|
+
.DS_Store
|
|
60
|
+
Thumbs.db
|
|
61
|
+
|
|
62
|
+
.playwright-cli/*
|
|
63
|
+
.playwright-mcp/*
|
|
64
|
+
host/client_app/.playwright-cli/*
|
|
65
|
+
.superpowers/
|
|
66
|
+
.qa/
|
|
67
|
+
.verify/
|
|
68
|
+
/openapi.json
|
|
69
|
+
qa-shots/
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: simple_module_oidc
|
|
3
|
+
Version: 0.0.43
|
|
4
|
+
Summary: Generic OIDC authentication provider for simple_module — native Microsoft Entra ID, Auth0, Okta, Keycloak, and any OpenID Connect identity provider
|
|
5
|
+
Project-URL: Homepage, https://github.com/antosubash/simple_module_python
|
|
6
|
+
Project-URL: Repository, https://github.com/antosubash/simple_module_python
|
|
7
|
+
Author-email: Anto Subash <antosubash@live.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Keywords: authentication,azure-ad,entra,fastapi,oidc,simple-module
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Framework :: FastAPI
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.12
|
|
21
|
+
Requires-Dist: cryptography>=42
|
|
22
|
+
Requires-Dist: httpx<0.29,>=0.27
|
|
23
|
+
Requires-Dist: pyjwt[crypto]<3,>=2.8
|
|
24
|
+
Requires-Dist: simple-module-auth==0.0.43
|
|
25
|
+
Requires-Dist: simple-module-core==0.0.43
|
|
26
|
+
Requires-Dist: simple-module-db==0.0.43
|
|
27
|
+
Requires-Dist: simple-module-hosting==0.0.43
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# simple_module_oidc
|
|
31
|
+
|
|
32
|
+
Generic **OpenID Connect** authentication provider for simple_module — with
|
|
33
|
+
**native Microsoft Entra ID (Azure AD)** support, plus Auth0, Okta, Zitadel,
|
|
34
|
+
Authentik, Keycloak, and any OIDC-compliant identity provider.
|
|
35
|
+
|
|
36
|
+
The module is configured from a provider's OIDC **discovery document**
|
|
37
|
+
(`.well-known/openid-configuration`), so endpoints (authorize / token / JWKS /
|
|
38
|
+
logout) and the issuer are resolved automatically — no per-provider URL
|
|
39
|
+
templating. Provider differences (claim names, scopes) are config; **presets**
|
|
40
|
+
fill those in for you.
|
|
41
|
+
|
|
42
|
+
It auto-registers as the application's auth provider. Browser users are
|
|
43
|
+
redirected to the IdP's hosted login; mobile/API clients send a bearer token
|
|
44
|
+
which is validated against the provider's JWKS when `api_audience` is configured.
|
|
45
|
+
|
|
46
|
+
## Install
|
|
47
|
+
|
|
48
|
+
Add to your app's `pyproject.toml` dependencies instead of another auth provider
|
|
49
|
+
(e.g. `simple_module_users` / `simple_module_keycloak` — only one auth provider
|
|
50
|
+
can be active):
|
|
51
|
+
|
|
52
|
+
```toml
|
|
53
|
+
dependencies = [
|
|
54
|
+
"simple_module_oidc",
|
|
55
|
+
]
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Run `uv sync --all-packages` to install.
|
|
59
|
+
|
|
60
|
+
## Usage
|
|
61
|
+
|
|
62
|
+
Select `SM_AUTH_PROVIDER=oidc` explicitly. Keep `Auth` enabled; `Settings` UI
|
|
63
|
+
is optional. Exclude Users-dependent modules when selecting this provider.
|
|
64
|
+
|
|
65
|
+
## Keycloak through the generic preset
|
|
66
|
+
|
|
67
|
+
This is the primary worked example. The dedicated `keycloak` module is an
|
|
68
|
+
alternative; this configuration uses `oidc` throughout.
|
|
69
|
+
|
|
70
|
+
Create an OpenID Connect client in the Keycloak realm:
|
|
71
|
+
|
|
72
|
+
- Enable client authentication (a confidential client) and the standard flow.
|
|
73
|
+
- Set the valid redirect URI to `<app>/api/oidc/auth/callback`.
|
|
74
|
+
- Set the valid post-logout redirect URI to `<app>/oidc/logged-out`.
|
|
75
|
+
- Require PKCE with code challenge method **S256**.
|
|
76
|
+
- Add realm roles to the **ID token**. Keycloak 26 puts `realm_access.roles`
|
|
77
|
+
only in the access token by default, and this module reads roles from the ID
|
|
78
|
+
token. Under Client scopes -> `roles` -> Mappers -> `realm roles`, turn on
|
|
79
|
+
**Add to ID token** (token claim name `realm_access.roles`, multivalued), or
|
|
80
|
+
add a **User Realm Role** mapper with those settings to the client's
|
|
81
|
+
dedicated scope. Then assign the intended realm roles (for example `admin`)
|
|
82
|
+
to users.
|
|
83
|
+
- Keep the default `profile` and `email` client scopes: they supply
|
|
84
|
+
`preferred_username`, `name`, `email` and `email_verified` in the ID token.
|
|
85
|
+
|
|
86
|
+
Use the realm's discovery document and the client's credentials:
|
|
87
|
+
|
|
88
|
+
```dotenv
|
|
89
|
+
SM_AUTH_PROVIDER=oidc
|
|
90
|
+
SM_OIDC_PROVIDER=generic
|
|
91
|
+
SM_OIDC_DISCOVERY_URL=https://<server>/realms/<realm>/.well-known/openid-configuration
|
|
92
|
+
SM_OIDC_CLIENT_ID=<confidential-client-id>
|
|
93
|
+
SM_OIDC_CLIENT_SECRET=<client-secret>
|
|
94
|
+
SM_OIDC_ROLES_CLAIM_PATH=realm_access.roles
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
`SM_OIDC_ROLES_CLAIM_PATH` seeds the roles path so the first Keycloak admin is
|
|
98
|
+
mapped to `admin` on first sign-in (the Settings UI that edits it needs an
|
|
99
|
+
admin). A value saved in the UI later wins.
|
|
100
|
+
|
|
101
|
+
Here `<app>` includes the application's scheme and host, and `<server>` is the
|
|
102
|
+
Keycloak server (including any deployment prefix). Discovery is exactly
|
|
103
|
+
`<server>/realms/<realm>/.well-known/openid-configuration` when `<server>` already
|
|
104
|
+
includes the scheme. Use HTTPS outside development.
|
|
105
|
+
|
|
106
|
+
Set these **DB settings** under the `oidc` namespace, using Settings → OIDC when
|
|
107
|
+
the Settings UI is enabled:
|
|
108
|
+
|
|
109
|
+
```json
|
|
110
|
+
{
|
|
111
|
+
"role_mapping": {"admin": "admin", "user": "user"}
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Choose external roles and app roles appropriate for your host. `role_mapping`
|
|
116
|
+
produces application roles; permission grants still come from the framework's
|
|
117
|
+
permission registry. No mapping grants `tenant:*` roles. Visit `/oidc/login` to
|
|
118
|
+
start sign-in. Cached identity is `(issuer, sub)` with a stable framework UUID.
|
|
119
|
+
|
|
120
|
+
## Native Microsoft Entra ID
|
|
121
|
+
|
|
122
|
+
Set the `entra` preset and your single-tenant registration details:
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
SM_AUTH_PROVIDER=oidc
|
|
126
|
+
SM_OIDC_PROVIDER=entra
|
|
127
|
+
SM_OIDC_TENANT_ID=<your-tenant-guid>
|
|
128
|
+
SM_OIDC_CLIENT_ID=<app-registration-client-id>
|
|
129
|
+
SM_OIDC_CLIENT_SECRET=<client-secret>
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
In the Entra app registration, add the redirect URI:
|
|
133
|
+
|
|
134
|
+
```
|
|
135
|
+
https://<your-host>/api/oidc/auth/callback
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
The `entra` preset derives the discovery URL
|
|
139
|
+
(`https://login.microsoftonline.com/{tenant_id}/v2.0/.well-known/openid-configuration`),
|
|
140
|
+
validates the **id_token**, keys the user cache on **(issuer, `oid`)**, and
|
|
141
|
+
maps Entra **app roles** (the `roles` claim) to application roles via
|
|
142
|
+
`role_mapping` (default: `admin` and `user` map 1:1). Assign app roles in
|
|
143
|
+
*Entra → App registrations → App roles* and *Enterprise applications → Users and groups*.
|
|
144
|
+
|
|
145
|
+
## Any other OIDC provider
|
|
146
|
+
|
|
147
|
+
Use the `generic` preset with an explicit discovery URL:
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
SM_AUTH_PROVIDER=oidc
|
|
151
|
+
SM_OIDC_PROVIDER=generic
|
|
152
|
+
SM_OIDC_DISCOVERY_URL=https://<issuer>/.well-known/openid-configuration
|
|
153
|
+
SM_OIDC_CLIENT_ID=<client-id>
|
|
154
|
+
SM_OIDC_CLIENT_SECRET=<client-secret>
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## Configuration reference
|
|
158
|
+
|
|
159
|
+
| Env var | Default | Notes |
|
|
160
|
+
|---|---|---|
|
|
161
|
+
| `SM_OIDC_PROVIDER` | `generic` | Preset name (`entra`, `generic`). |
|
|
162
|
+
| `SM_OIDC_DISCOVERY_URL` | — | Explicit discovery URL; wins over the preset. |
|
|
163
|
+
| `SM_OIDC_TENANT_ID` | — | Used by templated presets (Entra) to derive the discovery URL. |
|
|
164
|
+
| `SM_OIDC_CLIENT_ID` | — | OAuth client id. |
|
|
165
|
+
| `SM_OIDC_CLIENT_SECRET` | — | OAuth client secret. |
|
|
166
|
+
| `SM_OIDC_ROLES_CLAIM_PATH` | preset | Seeds `roles_claim_path`, so the first admin can sign in before the Settings UI is reachable. |
|
|
167
|
+
|
|
168
|
+
Only the six environment variables above bootstrap process wiring, secrets and the first admin's roles path.
|
|
169
|
+
These variables seed defaults; explicit DB values can override them.
|
|
170
|
+
All other settings are DB-backed; bare environment names and the old claim
|
|
171
|
+
mapping environment variables are ignored. Provider, discovery, tenant, client
|
|
172
|
+
credentials and JWKS cache TTL changes require a restart. Claim and role mapping
|
|
173
|
+
and tenant-claim trust read the live settings state.
|
|
174
|
+
|
|
175
|
+
| DB setting | Default | Behaviour |
|
|
176
|
+
|---|---|---|
|
|
177
|
+
| `api_audience` | empty | Bearer authentication disabled until configured; ID tokens always use `client_id`. |
|
|
178
|
+
| `api_allowed_client_ids` | empty | Clients besides `client_id` whose API tokens (`azp`, or `appid` on Entra v1) are accepted; a token naming no client is refused. |
|
|
179
|
+
| `trust_tenant_claim` | false | Opt in only to a trusted, validated `tenant_id` claim. Entra `tid` never selects an application tenant. Membership resolution stays authoritative. |
|
|
180
|
+
| `scope` | preset | Entra requests `openid email profile offline_access`. |
|
|
181
|
+
| `uid_claim` | `oid` for Entra, otherwise `sub` | Required identity claim; there is no fallback. Identities use unique (issuer, subject) UUID mappings. |
|
|
182
|
+
| `username_claim` / `name_claim` | `preferred_username` / `name` | Display name with fallback. |
|
|
183
|
+
| `email_claim` | `email` | Display email; only generic OIDC literal `email_verified=true` attests ownership. Entra never attests it. |
|
|
184
|
+
| `roles_claim_path` | `roles` for Entra, otherwise empty | Dotted claim path to role list. |
|
|
185
|
+
| `role_mapping` | admin/user map to themselves | External roles to application roles; `tenant:*` roles are filtered on login and restored sessions. |
|
|
186
|
+
| `login_redirect_url` | `/dashboard/` | Blank values normalize to the default. Saved deep links must be local paths. |
|
|
187
|
+
| `jwks_cache_ttl_seconds` | 3600 | Last-good signing keys retained on outage; unknown keys share a 30-second fetch cooldown. |
|
|
188
|
+
|
|
189
|
+
## Login, sessions and logout
|
|
190
|
+
|
|
191
|
+
Entra supports a single tenant GUID. `common`, `organizations` and `consumers`
|
|
192
|
+
are rejected. Unknown presets are validation errors. Discovery requires an issuer
|
|
193
|
+
matching the configured discovery URL and HTTPS outside development; different
|
|
194
|
+
endpoint hosts are supported. Discovery failure refuses boot outside development;
|
|
195
|
+
development retries lazily on login.
|
|
196
|
+
|
|
197
|
+
Authorization uses code flow with S256 PKCE, state and constant-time nonce
|
|
198
|
+
validation. JWTs require `iss`, `sub`, `aud`, `exp` and `iat`, RS256 and the
|
|
199
|
+
expected audience. Additional audiences are rejected, and a supplied `azp` on a browser ID token must name this client (required with multiple
|
|
200
|
+
audiences); an API access token's `azp` is the calling client, checked against `api_allowed_client_ids` instead. The returned access token is unused, so `at_hash` is intentionally
|
|
201
|
+
not checked.
|
|
202
|
+
|
|
203
|
+
Sign-in replaces user-bound session state and rotates CSRF. The cookie never
|
|
204
|
+
stores the ID token; it stores the ID-token expiry and an encrypted refresh token
|
|
205
|
+
when supplied. Expired or unbounded sessions renew using a fresh ID token, with
|
|
206
|
+
the same issuer and configured subject required. Definitive refresh rejection
|
|
207
|
+
clears authentication. Providers that do not return an `id_token` on refresh
|
|
208
|
+
sign the user out at expiry. Transport failures, 5xx and retryable 4xx preserve the
|
|
209
|
+
expired session for another attempt, while this request stays unauthenticated;
|
|
210
|
+
the token endpoint is not called again for 30 seconds, so a flapping IdP does not
|
|
211
|
+
stall every request. A refresh token too large for the 4 KB session cookie (Entra
|
|
212
|
+
can issue such tokens) is not stored, and that session ends at ID-token expiry.
|
|
213
|
+
|
|
214
|
+
Logout best-effort revokes the refresh token when discovery advertises a
|
|
215
|
+
`revocation_endpoint`, then always clears local state. The optional discovered logout endpoint gets
|
|
216
|
+
`client_id` and `post_logout_redirect_uri`, without `id_token_hint`. Register
|
|
217
|
+
`https://<your-host>/oidc/logged-out` as the post-logout URI. Providers requiring
|
|
218
|
+
an ID-token hint may show a confirmation prompt or leave their SSO session active;
|
|
219
|
+
the local signed-out page never automatically signs in.
|
|
220
|
+
|
|
221
|
+
The host migration `c9d2e4f6a810` (`oidc@head`, #192) creates the identity cache.
|
|
222
|
+
Apply host migrations with `upgrade heads` before deployment.
|
|
223
|
+
|
|
224
|
+
## Activation
|
|
225
|
+
|
|
226
|
+
Add `simple_module_oidc` to the host's `pyproject.toml` dependencies and
|
|
227
|
+
`[tool.uv.sources]`, remove the competing provider, then `uv sync --all-packages`.
|
|
228
|
+
Alternatively scope active modules with `SM_MODULES_ENABLED`.
|
|
229
|
+
|
|
230
|
+
See [the full configuration reference](../../docs/modules/oidc.md)
|
|
231
|
+
for every setting, its environment support, tenancy policy and deployment limits.
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# simple_module_oidc
|
|
2
|
+
|
|
3
|
+
Generic **OpenID Connect** authentication provider for simple_module — with
|
|
4
|
+
**native Microsoft Entra ID (Azure AD)** support, plus Auth0, Okta, Zitadel,
|
|
5
|
+
Authentik, Keycloak, and any OIDC-compliant identity provider.
|
|
6
|
+
|
|
7
|
+
The module is configured from a provider's OIDC **discovery document**
|
|
8
|
+
(`.well-known/openid-configuration`), so endpoints (authorize / token / JWKS /
|
|
9
|
+
logout) and the issuer are resolved automatically — no per-provider URL
|
|
10
|
+
templating. Provider differences (claim names, scopes) are config; **presets**
|
|
11
|
+
fill those in for you.
|
|
12
|
+
|
|
13
|
+
It auto-registers as the application's auth provider. Browser users are
|
|
14
|
+
redirected to the IdP's hosted login; mobile/API clients send a bearer token
|
|
15
|
+
which is validated against the provider's JWKS when `api_audience` is configured.
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
Add to your app's `pyproject.toml` dependencies instead of another auth provider
|
|
20
|
+
(e.g. `simple_module_users` / `simple_module_keycloak` — only one auth provider
|
|
21
|
+
can be active):
|
|
22
|
+
|
|
23
|
+
```toml
|
|
24
|
+
dependencies = [
|
|
25
|
+
"simple_module_oidc",
|
|
26
|
+
]
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Run `uv sync --all-packages` to install.
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
Select `SM_AUTH_PROVIDER=oidc` explicitly. Keep `Auth` enabled; `Settings` UI
|
|
34
|
+
is optional. Exclude Users-dependent modules when selecting this provider.
|
|
35
|
+
|
|
36
|
+
## Keycloak through the generic preset
|
|
37
|
+
|
|
38
|
+
This is the primary worked example. The dedicated `keycloak` module is an
|
|
39
|
+
alternative; this configuration uses `oidc` throughout.
|
|
40
|
+
|
|
41
|
+
Create an OpenID Connect client in the Keycloak realm:
|
|
42
|
+
|
|
43
|
+
- Enable client authentication (a confidential client) and the standard flow.
|
|
44
|
+
- Set the valid redirect URI to `<app>/api/oidc/auth/callback`.
|
|
45
|
+
- Set the valid post-logout redirect URI to `<app>/oidc/logged-out`.
|
|
46
|
+
- Require PKCE with code challenge method **S256**.
|
|
47
|
+
- Add realm roles to the **ID token**. Keycloak 26 puts `realm_access.roles`
|
|
48
|
+
only in the access token by default, and this module reads roles from the ID
|
|
49
|
+
token. Under Client scopes -> `roles` -> Mappers -> `realm roles`, turn on
|
|
50
|
+
**Add to ID token** (token claim name `realm_access.roles`, multivalued), or
|
|
51
|
+
add a **User Realm Role** mapper with those settings to the client's
|
|
52
|
+
dedicated scope. Then assign the intended realm roles (for example `admin`)
|
|
53
|
+
to users.
|
|
54
|
+
- Keep the default `profile` and `email` client scopes: they supply
|
|
55
|
+
`preferred_username`, `name`, `email` and `email_verified` in the ID token.
|
|
56
|
+
|
|
57
|
+
Use the realm's discovery document and the client's credentials:
|
|
58
|
+
|
|
59
|
+
```dotenv
|
|
60
|
+
SM_AUTH_PROVIDER=oidc
|
|
61
|
+
SM_OIDC_PROVIDER=generic
|
|
62
|
+
SM_OIDC_DISCOVERY_URL=https://<server>/realms/<realm>/.well-known/openid-configuration
|
|
63
|
+
SM_OIDC_CLIENT_ID=<confidential-client-id>
|
|
64
|
+
SM_OIDC_CLIENT_SECRET=<client-secret>
|
|
65
|
+
SM_OIDC_ROLES_CLAIM_PATH=realm_access.roles
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`SM_OIDC_ROLES_CLAIM_PATH` seeds the roles path so the first Keycloak admin is
|
|
69
|
+
mapped to `admin` on first sign-in (the Settings UI that edits it needs an
|
|
70
|
+
admin). A value saved in the UI later wins.
|
|
71
|
+
|
|
72
|
+
Here `<app>` includes the application's scheme and host, and `<server>` is the
|
|
73
|
+
Keycloak server (including any deployment prefix). Discovery is exactly
|
|
74
|
+
`<server>/realms/<realm>/.well-known/openid-configuration` when `<server>` already
|
|
75
|
+
includes the scheme. Use HTTPS outside development.
|
|
76
|
+
|
|
77
|
+
Set these **DB settings** under the `oidc` namespace, using Settings → OIDC when
|
|
78
|
+
the Settings UI is enabled:
|
|
79
|
+
|
|
80
|
+
```json
|
|
81
|
+
{
|
|
82
|
+
"role_mapping": {"admin": "admin", "user": "user"}
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Choose external roles and app roles appropriate for your host. `role_mapping`
|
|
87
|
+
produces application roles; permission grants still come from the framework's
|
|
88
|
+
permission registry. No mapping grants `tenant:*` roles. Visit `/oidc/login` to
|
|
89
|
+
start sign-in. Cached identity is `(issuer, sub)` with a stable framework UUID.
|
|
90
|
+
|
|
91
|
+
## Native Microsoft Entra ID
|
|
92
|
+
|
|
93
|
+
Set the `entra` preset and your single-tenant registration details:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
SM_AUTH_PROVIDER=oidc
|
|
97
|
+
SM_OIDC_PROVIDER=entra
|
|
98
|
+
SM_OIDC_TENANT_ID=<your-tenant-guid>
|
|
99
|
+
SM_OIDC_CLIENT_ID=<app-registration-client-id>
|
|
100
|
+
SM_OIDC_CLIENT_SECRET=<client-secret>
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
In the Entra app registration, add the redirect URI:
|
|
104
|
+
|
|
105
|
+
```
|
|
106
|
+
https://<your-host>/api/oidc/auth/callback
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
The `entra` preset derives the discovery URL
|
|
110
|
+
(`https://login.microsoftonline.com/{tenant_id}/v2.0/.well-known/openid-configuration`),
|
|
111
|
+
validates the **id_token**, keys the user cache on **(issuer, `oid`)**, and
|
|
112
|
+
maps Entra **app roles** (the `roles` claim) to application roles via
|
|
113
|
+
`role_mapping` (default: `admin` and `user` map 1:1). Assign app roles in
|
|
114
|
+
*Entra → App registrations → App roles* and *Enterprise applications → Users and groups*.
|
|
115
|
+
|
|
116
|
+
## Any other OIDC provider
|
|
117
|
+
|
|
118
|
+
Use the `generic` preset with an explicit discovery URL:
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
SM_AUTH_PROVIDER=oidc
|
|
122
|
+
SM_OIDC_PROVIDER=generic
|
|
123
|
+
SM_OIDC_DISCOVERY_URL=https://<issuer>/.well-known/openid-configuration
|
|
124
|
+
SM_OIDC_CLIENT_ID=<client-id>
|
|
125
|
+
SM_OIDC_CLIENT_SECRET=<client-secret>
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Configuration reference
|
|
129
|
+
|
|
130
|
+
| Env var | Default | Notes |
|
|
131
|
+
|---|---|---|
|
|
132
|
+
| `SM_OIDC_PROVIDER` | `generic` | Preset name (`entra`, `generic`). |
|
|
133
|
+
| `SM_OIDC_DISCOVERY_URL` | — | Explicit discovery URL; wins over the preset. |
|
|
134
|
+
| `SM_OIDC_TENANT_ID` | — | Used by templated presets (Entra) to derive the discovery URL. |
|
|
135
|
+
| `SM_OIDC_CLIENT_ID` | — | OAuth client id. |
|
|
136
|
+
| `SM_OIDC_CLIENT_SECRET` | — | OAuth client secret. |
|
|
137
|
+
| `SM_OIDC_ROLES_CLAIM_PATH` | preset | Seeds `roles_claim_path`, so the first admin can sign in before the Settings UI is reachable. |
|
|
138
|
+
|
|
139
|
+
Only the six environment variables above bootstrap process wiring, secrets and the first admin's roles path.
|
|
140
|
+
These variables seed defaults; explicit DB values can override them.
|
|
141
|
+
All other settings are DB-backed; bare environment names and the old claim
|
|
142
|
+
mapping environment variables are ignored. Provider, discovery, tenant, client
|
|
143
|
+
credentials and JWKS cache TTL changes require a restart. Claim and role mapping
|
|
144
|
+
and tenant-claim trust read the live settings state.
|
|
145
|
+
|
|
146
|
+
| DB setting | Default | Behaviour |
|
|
147
|
+
|---|---|---|
|
|
148
|
+
| `api_audience` | empty | Bearer authentication disabled until configured; ID tokens always use `client_id`. |
|
|
149
|
+
| `api_allowed_client_ids` | empty | Clients besides `client_id` whose API tokens (`azp`, or `appid` on Entra v1) are accepted; a token naming no client is refused. |
|
|
150
|
+
| `trust_tenant_claim` | false | Opt in only to a trusted, validated `tenant_id` claim. Entra `tid` never selects an application tenant. Membership resolution stays authoritative. |
|
|
151
|
+
| `scope` | preset | Entra requests `openid email profile offline_access`. |
|
|
152
|
+
| `uid_claim` | `oid` for Entra, otherwise `sub` | Required identity claim; there is no fallback. Identities use unique (issuer, subject) UUID mappings. |
|
|
153
|
+
| `username_claim` / `name_claim` | `preferred_username` / `name` | Display name with fallback. |
|
|
154
|
+
| `email_claim` | `email` | Display email; only generic OIDC literal `email_verified=true` attests ownership. Entra never attests it. |
|
|
155
|
+
| `roles_claim_path` | `roles` for Entra, otherwise empty | Dotted claim path to role list. |
|
|
156
|
+
| `role_mapping` | admin/user map to themselves | External roles to application roles; `tenant:*` roles are filtered on login and restored sessions. |
|
|
157
|
+
| `login_redirect_url` | `/dashboard/` | Blank values normalize to the default. Saved deep links must be local paths. |
|
|
158
|
+
| `jwks_cache_ttl_seconds` | 3600 | Last-good signing keys retained on outage; unknown keys share a 30-second fetch cooldown. |
|
|
159
|
+
|
|
160
|
+
## Login, sessions and logout
|
|
161
|
+
|
|
162
|
+
Entra supports a single tenant GUID. `common`, `organizations` and `consumers`
|
|
163
|
+
are rejected. Unknown presets are validation errors. Discovery requires an issuer
|
|
164
|
+
matching the configured discovery URL and HTTPS outside development; different
|
|
165
|
+
endpoint hosts are supported. Discovery failure refuses boot outside development;
|
|
166
|
+
development retries lazily on login.
|
|
167
|
+
|
|
168
|
+
Authorization uses code flow with S256 PKCE, state and constant-time nonce
|
|
169
|
+
validation. JWTs require `iss`, `sub`, `aud`, `exp` and `iat`, RS256 and the
|
|
170
|
+
expected audience. Additional audiences are rejected, and a supplied `azp` on a browser ID token must name this client (required with multiple
|
|
171
|
+
audiences); an API access token's `azp` is the calling client, checked against `api_allowed_client_ids` instead. The returned access token is unused, so `at_hash` is intentionally
|
|
172
|
+
not checked.
|
|
173
|
+
|
|
174
|
+
Sign-in replaces user-bound session state and rotates CSRF. The cookie never
|
|
175
|
+
stores the ID token; it stores the ID-token expiry and an encrypted refresh token
|
|
176
|
+
when supplied. Expired or unbounded sessions renew using a fresh ID token, with
|
|
177
|
+
the same issuer and configured subject required. Definitive refresh rejection
|
|
178
|
+
clears authentication. Providers that do not return an `id_token` on refresh
|
|
179
|
+
sign the user out at expiry. Transport failures, 5xx and retryable 4xx preserve the
|
|
180
|
+
expired session for another attempt, while this request stays unauthenticated;
|
|
181
|
+
the token endpoint is not called again for 30 seconds, so a flapping IdP does not
|
|
182
|
+
stall every request. A refresh token too large for the 4 KB session cookie (Entra
|
|
183
|
+
can issue such tokens) is not stored, and that session ends at ID-token expiry.
|
|
184
|
+
|
|
185
|
+
Logout best-effort revokes the refresh token when discovery advertises a
|
|
186
|
+
`revocation_endpoint`, then always clears local state. The optional discovered logout endpoint gets
|
|
187
|
+
`client_id` and `post_logout_redirect_uri`, without `id_token_hint`. Register
|
|
188
|
+
`https://<your-host>/oidc/logged-out` as the post-logout URI. Providers requiring
|
|
189
|
+
an ID-token hint may show a confirmation prompt or leave their SSO session active;
|
|
190
|
+
the local signed-out page never automatically signs in.
|
|
191
|
+
|
|
192
|
+
The host migration `c9d2e4f6a810` (`oidc@head`, #192) creates the identity cache.
|
|
193
|
+
Apply host migrations with `upgrade heads` before deployment.
|
|
194
|
+
|
|
195
|
+
## Activation
|
|
196
|
+
|
|
197
|
+
Add `simple_module_oidc` to the host's `pyproject.toml` dependencies and
|
|
198
|
+
`[tool.uv.sources]`, remove the competing provider, then `uv sync --all-packages`.
|
|
199
|
+
Alternatively scope active modules with `SM_MODULES_ENABLED`.
|
|
200
|
+
|
|
201
|
+
See [the full configuration reference](../../docs/modules/oidc.md)
|
|
202
|
+
for every setting, its environment support, tenancy policy and deployment limits.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Generic OIDC authentication provider for simple_module (Entra, Auth0, Okta, ...)."""
|
|
@@ -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
|
|
@@ -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."""
|
|
@@ -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)
|