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.
Files changed (59) hide show
  1. simple_module_oidc-0.0.43/.gitignore +69 -0
  2. simple_module_oidc-0.0.43/PKG-INFO +231 -0
  3. simple_module_oidc-0.0.43/README.md +202 -0
  4. simple_module_oidc-0.0.43/oidc/__init__.py +1 -0
  5. simple_module_oidc-0.0.43/oidc/client.py +138 -0
  6. simple_module_oidc-0.0.43/oidc/constants.py +19 -0
  7. simple_module_oidc-0.0.43/oidc/contracts/__init__.py +1 -0
  8. simple_module_oidc-0.0.43/oidc/directory.py +10 -0
  9. simple_module_oidc-0.0.43/oidc/discovery.py +103 -0
  10. simple_module_oidc-0.0.43/oidc/endpoints/__init__.py +1 -0
  11. simple_module_oidc-0.0.43/oidc/endpoints/api.py +119 -0
  12. simple_module_oidc-0.0.43/oidc/endpoints/views.py +59 -0
  13. simple_module_oidc-0.0.43/oidc/jwks.py +5 -0
  14. simple_module_oidc-0.0.43/oidc/locales/de.json +14 -0
  15. simple_module_oidc-0.0.43/oidc/locales/en.json +14 -0
  16. simple_module_oidc-0.0.43/oidc/models.py +28 -0
  17. simple_module_oidc-0.0.43/oidc/module.py +87 -0
  18. simple_module_oidc-0.0.43/oidc/pages/LoggedOut.tsx +15 -0
  19. simple_module_oidc-0.0.43/oidc/pages/Login.tsx +20 -0
  20. simple_module_oidc-0.0.43/oidc/persistence.py +28 -0
  21. simple_module_oidc-0.0.43/oidc/presets.py +59 -0
  22. simple_module_oidc-0.0.43/oidc/provider.py +172 -0
  23. simple_module_oidc-0.0.43/oidc/py.typed +0 -0
  24. simple_module_oidc-0.0.43/oidc/renewal.py +85 -0
  25. simple_module_oidc-0.0.43/oidc/runtime.py +74 -0
  26. simple_module_oidc-0.0.43/oidc/settings.py +131 -0
  27. simple_module_oidc-0.0.43/oidc/state.py +25 -0
  28. simple_module_oidc-0.0.43/oidc/tokens.py +10 -0
  29. simple_module_oidc-0.0.43/package.json +7 -0
  30. simple_module_oidc-0.0.43/pyproject.toml +53 -0
  31. simple_module_oidc-0.0.43/tests/conftest.py +120 -0
  32. simple_module_oidc-0.0.43/tests/oidc_flow_helpers.py +44 -0
  33. simple_module_oidc-0.0.43/tests/test_oidc_authorization.py +116 -0
  34. simple_module_oidc-0.0.43/tests/test_oidc_authorization_query.py +41 -0
  35. simple_module_oidc-0.0.43/tests/test_oidc_bearer_policy.py +49 -0
  36. simple_module_oidc-0.0.43/tests/test_oidc_boot.py +68 -0
  37. simple_module_oidc-0.0.43/tests/test_oidc_cache_persistence.py +168 -0
  38. simple_module_oidc-0.0.43/tests/test_oidc_client.py +119 -0
  39. simple_module_oidc-0.0.43/tests/test_oidc_directory.py +76 -0
  40. simple_module_oidc-0.0.43/tests/test_oidc_discovery.py +105 -0
  41. simple_module_oidc-0.0.43/tests/test_oidc_endpoint_auth.py +216 -0
  42. simple_module_oidc-0.0.43/tests/test_oidc_flow.py +133 -0
  43. simple_module_oidc-0.0.43/tests/test_oidc_jwks.py +116 -0
  44. simple_module_oidc-0.0.43/tests/test_oidc_jwks_resilience.py +189 -0
  45. simple_module_oidc-0.0.43/tests/test_oidc_jwt_policy.py +124 -0
  46. simple_module_oidc-0.0.43/tests/test_oidc_keycloak_flow.py +116 -0
  47. simple_module_oidc-0.0.43/tests/test_oidc_legacy_authority.py +111 -0
  48. simple_module_oidc-0.0.43/tests/test_oidc_logout.py +96 -0
  49. simple_module_oidc-0.0.43/tests/test_oidc_migration.py +66 -0
  50. simple_module_oidc-0.0.43/tests/test_oidc_module.py +39 -0
  51. simple_module_oidc-0.0.43/tests/test_oidc_provider.py +131 -0
  52. simple_module_oidc-0.0.43/tests/test_oidc_refresh.py +249 -0
  53. simple_module_oidc-0.0.43/tests/test_oidc_session_transaction.py +222 -0
  54. simple_module_oidc-0.0.43/tests/test_oidc_settings_presets.py +119 -0
  55. simple_module_oidc-0.0.43/tests/test_oidc_settings_runtime.py +34 -0
  56. simple_module_oidc-0.0.43/tests/test_oidc_tenant_resolver.py +51 -0
  57. simple_module_oidc-0.0.43/tests-js/oidc-login.test.tsx +25 -0
  58. simple_module_oidc-0.0.43/tests-js/oidc-translations.test.tsx +45 -0
  59. 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)