xtr-security-openid-connect-server 6.3.0__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 (50) hide show
  1. xtr_security_openid_connect_server-6.3.0/LICENSE +22 -0
  2. xtr_security_openid_connect_server-6.3.0/PKG-INFO +530 -0
  3. xtr_security_openid_connect_server-6.3.0/README.md +496 -0
  4. xtr_security_openid_connect_server-6.3.0/pyproject.toml +185 -0
  5. xtr_security_openid_connect_server-6.3.0/pyproject.toml.orig +186 -0
  6. xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/.agents/skills/xtr-security-openid-connect-server/SKILL.md +332 -0
  7. xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/.agents/skills/xtr-security-openid-connect-server/references/configuration.md +95 -0
  8. xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/.agents/skills/xtr-security-openid-connect-server/references/flows.md +99 -0
  9. xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/__init__.py +21 -0
  10. xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/_endpoint_paths.py +32 -0
  11. xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/bundle/__init__.py +17 -0
  12. xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/bundle/_service_factories.py +188 -0
  13. xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/bundle/openid_connect_server_bundle.py +230 -0
  14. xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/bundle/openid_connect_server_config.py +98 -0
  15. xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/claim_extractor.py +152 -0
  16. xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/controller/__init__.py +11 -0
  17. xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/controller/user_info_controller.py +171 -0
  18. xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/entities/__init__.py +26 -0
  19. xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/entities/claim_set_entity.py +62 -0
  20. xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/entities/claim_set_entity_interface.py +28 -0
  21. xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/entities/claim_set_interface.py +38 -0
  22. xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/entities/user_claims_entity_interface.py +28 -0
  23. xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/event_listener/__init__.py +12 -0
  24. xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/event_listener/auth_time_listener.py +52 -0
  25. xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/exception/__init__.py +29 -0
  26. xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/exception/invalid_argument_error.py +24 -0
  27. xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/exception/invalid_configuration_error.py +28 -0
  28. xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/exception/open_id_connect_server_error.py +21 -0
  29. xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/exception/unsupported_algorithm_error.py +47 -0
  30. xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/firewall/__init__.py +12 -0
  31. xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/firewall/open_id_prompt_listener.py +287 -0
  32. xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/grant/__init__.py +13 -0
  33. xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/grant/open_id_auth_code_grant.py +242 -0
  34. xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/id_token_settings.py +63 -0
  35. xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/open_id_metadata_contributor.py +112 -0
  36. xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/prompt.py +126 -0
  37. xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/py.typed +0 -0
  38. xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/recipe/__init__.py +14 -0
  39. xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/recipe/files/config/openid_connect_server.py.tmpl +46 -0
  40. xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/recipe/files/config/routes/openid_connect_server.py.tmpl +37 -0
  41. xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/recipe/manifest.toml +36 -0
  42. xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/repositories/__init__.py +13 -0
  43. xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/repositories/identity_provider_interface.py +55 -0
  44. xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/request_types/__init__.py +15 -0
  45. xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/request_types/open_id_authorization_request.py +88 -0
  46. xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/response_types/__init__.py +12 -0
  47. xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/response_types/id_token_response.py +161 -0
  48. xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/routing/__init__.py +28 -0
  49. xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/routing/_endpoints.py +103 -0
  50. xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/token_hash.py +84 -0
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 xterr
4
+ Copyright (c) 2018 Steve Rhoades <sedonami@gmail.com>
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
@@ -0,0 +1,530 @@
1
+ Metadata-Version: 2.4
2
+ Name: xtr-security-openid-connect-server
3
+ Version: 6.3.0
4
+ Summary: OpenID Connect for the xtr OAuth 2.0 authorization server: ID tokens, userinfo and discovery.
5
+ Keywords: security,openid-connect,oidc,oauth2,authorization-server,id-token,authentication
6
+ Author: Xterr
7
+ Author-email: Xterr <me@xterr.dev>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Programming Language :: Python :: 3.14
16
+ Classifier: Typing :: Typed
17
+ Requires-Dist: xtr-security-oauth2-server>=6.0,<7
18
+ Requires-Dist: xtr-security-oauth2-server-core>=6.0,<7
19
+ Requires-Dist: xtr-security-core>=6.0,<7
20
+ Requires-Dist: xtr-security-http>=6.0,<7
21
+ Requires-Dist: xtr-security>=6.0,<7
22
+ Requires-Dist: xtr-security-jwt>=6.0,<7
23
+ Requires-Dist: xtr-clock>=6.0,<7
24
+ Requires-Dist: xtr-event-dispatcher-contracts>=6.0,<7
25
+ Requires-Dist: xtr-event-dispatcher>=6.0,<7
26
+ Requires-Dist: xtr-dependency-injection>=6.0,<7
27
+ Requires-Dist: xtr-http-message-contracts>=6.0,<7
28
+ Requires-Dist: xtr-http-kernel>=6.0,<7
29
+ Requires-Dist: fastapi>=0.123
30
+ Requires-Dist: typing-extensions>=4.12
31
+ Requires-Dist: python-multipart>=0.0.9
32
+ Requires-Python: >=3.11
33
+ Description-Content-Type: text/markdown
34
+
35
+ <div align="center">
36
+
37
+ # xtr-security-openid-connect-server
38
+
39
+ **OpenID Connect for the xtr OAuth 2.0 authorization server.**
40
+
41
+ <img alt="python 3.11+" src="https://img.shields.io/badge/python-%E2%89%A5%203.11-3776AB?logo=python&logoColor=white">
42
+ <img alt="typed" src="https://img.shields.io/badge/typed-ty%20%2B%20basedpyright-1f6feb">
43
+ <img alt="license MIT" src="https://img.shields.io/badge/license-MIT-blue">
44
+
45
+ </div>
46
+
47
+ ---
48
+
49
+ ## Why?
50
+
51
+ [xtr-security-oauth2-server](../xtr-security-oauth2-server) issues access tokens: a client that
52
+ holds one may act for somebody, and learns nothing about who that somebody is. "Sign in with this
53
+ server" is the other half, and this package is it — an add-on bundle that turns that server into
54
+ an OpenID Connect provider for the authorization code flow, without changing how the server works.
55
+
56
+ - 🪪 **An ID token** — the token answer to a sign-in carries a signed `id_token` naming the
57
+ end-user: `iss`, `sub`, `aud`, `exp`, `iat`, `auth_time`, the `nonce` the client sent, and an
58
+ `at_hash` binding the access token answered beside it.
59
+ - 👤 **`/userinfo`** — `GET` and `POST`; answers the subject plus the claims the token's scopes
60
+ reveal, from the application's own users, and fails closed.
61
+ - 🔎 **`/.well-known/openid-configuration`** — one document: the authorization server's own
62
+ description, with the provider's fields contributed to it.
63
+ - 🔁 **`prompt` and `max_age`** — `none` answers `login_required` rather than showing a page,
64
+ `login` and an expired `max_age` send the end-user back through the login page and return to the
65
+ request, `consent` is read by the application's consent listener.
66
+ - 🔑 **One key, one key set** — the ID token is signed with the authorization server's own signer
67
+ and `kid`, so a client verifies it from the `/.well-known/jwks.json` that server already
68
+ publishes.
69
+
70
+ It follows OpenID Connect Core 1.0 and Discovery 1.0 for the authorization code flow, checked by
71
+ `tests/conformance/`, whose README lists every requirement, every deliberate deviation and the
72
+ optional manual run of the OpenID Foundation suite. Running that suite is not a claim this
73
+ repository makes.
74
+
75
+ Out of scope on purpose: no logout of any kind, no implicit or hybrid flow, no pairwise subjects,
76
+ no `claims` parameter, no request objects, no dynamic client registration, and no ID token on a
77
+ refresh.
78
+
79
+ ## Install
80
+
81
+ ```sh
82
+ uv add xtr-security-openid-connect-server # the provider, its bundle and its endpoints
83
+ ```
84
+
85
+ Requires Python 3.11+. It brings [xtr-security-oauth2-server](../xtr-security-oauth2-server) and
86
+ its protocol library along, with the security family, the key layer, the clock, the event
87
+ dispatcher and the HTTP kernel.
88
+
89
+ ## Quick start
90
+
91
+ A deployment whose authorization server already runs the code flow, signing end-users in.
92
+
93
+ ### 1. List both bundles
94
+
95
+ ```python
96
+ # app/config/bundles.py
97
+ from xtr_security_oauth2_server.bundle import OAuth2ServerBundle
98
+ from xtr_security_openid_connect_server.bundle import OpenIdConnectServerBundle
99
+
100
+ BUNDLES = {OAuth2ServerBundle: {"all": True}, OpenIdConnectServerBundle: {"all": True}}
101
+ ```
102
+
103
+ Listing the provider alone is enough: it requires the authorization server's bundle. The provider
104
+ puts its own flow and token answer in place of the shipped ones, and adds `openid` to
105
+ `scopes.available` when that tuple names anything at all.
106
+
107
+ ### 2. Say where the users are
108
+
109
+ ```python
110
+ # app/identity_provider.py
111
+ from __future__ import annotations
112
+
113
+ from typing import TYPE_CHECKING, final
114
+
115
+ from xtr_security_core.exception import UserNotFoundError
116
+
117
+ if TYPE_CHECKING:
118
+ from collections.abc import Mapping
119
+
120
+ from xtr_security_oauth2_server_core.entities.client_entity_interface import (
121
+ ClientEntityInterface,
122
+ )
123
+
124
+ from xtr_security_openid_connect_server.entities import UserClaimsEntityInterface
125
+
126
+
127
+ @final
128
+ class Person:
129
+ """A resource owner the server knows by identifier, with the claims they hold."""
130
+
131
+ def __init__(self, identifier: str, claims: Mapping[str, object]) -> None:
132
+ self._identifier = identifier
133
+ self._claims = claims
134
+
135
+ def get_identifier(self) -> str:
136
+ return self._identifier
137
+
138
+ async def get_claims(self) -> Mapping[str, object]:
139
+ return self._claims
140
+
141
+
142
+ @final
143
+ class PeopleIdentityProvider:
144
+ """Finds the end-user a token names, and the claims that describe them."""
145
+
146
+ async def get_user_entity_by_identifier(
147
+ self,
148
+ identifier: str,
149
+ client: ClientEntityInterface,
150
+ ) -> UserClaimsEntityInterface:
151
+ del client # Every client is told the same about a person here.
152
+ if identifier != "alice":
153
+ raise UserNotFoundError(identifier)
154
+ return Person(
155
+ "alice",
156
+ {"name": "Alice", "email": "alice@example.com", "email_verified": True},
157
+ )
158
+ ```
159
+
160
+ ```python
161
+ # app/config/packages/openid_connect_server.py
162
+ from xtr_dependency_injection import configure
163
+ from xtr_security_openid_connect_server.bundle import OpenIdConnectServerConfig
164
+
165
+ from app.identity_provider import PeopleIdentityProvider
166
+
167
+ __all__ = ["openid_connect_server"]
168
+
169
+
170
+ @configure
171
+ def openid_connect_server() -> OpenIdConnectServerConfig:
172
+ return OpenIdConnectServerConfig(
173
+ identity_provider=PeopleIdentityProvider,
174
+ login_path="/login",
175
+ login_firewall="main",
176
+ )
177
+ ```
178
+
179
+ The bundle registers the class named here, so the container builds it from its constructor. Do not
180
+ register it a second time.
181
+
182
+ ### 3. Serve the endpoints and guard them
183
+
184
+ ```python
185
+ # app/config/routes/openid_connect_server.py
186
+ from __future__ import annotations
187
+
188
+ from typing import TYPE_CHECKING
189
+
190
+ from xtr_http_kernel.routing.decorator import as_routes
191
+
192
+ if TYPE_CHECKING:
193
+ from collections.abc import Mapping
194
+
195
+ from fastapi import APIRouter, FastAPI
196
+
197
+ __all__ = ["openid_connect_server"]
198
+
199
+
200
+ @as_routes
201
+ def openid_connect_server(app: FastAPI, resources: Mapping[str, APIRouter]) -> None:
202
+ app.include_router(resources["openid_connect_server"])
203
+ app.include_router(resources["openid_connect_server_well_known"])
204
+ ```
205
+
206
+ ```python
207
+ # app/config/packages/security.py — the two firewalls this package needs
208
+ from xtr_dependency_injection import configure
209
+ from xtr_security.bundle import FirewallConfig, SecurityConfig
210
+ from xtr_security_jwt.bundle import JwtAuthenticatorConfig
211
+ from xtr_security_oauth2_server.bundle import OAuth2AuthenticatorConfig
212
+ from xtr_security_openid_connect_server.bundle import PROMPT_LISTENER
213
+
214
+ __all__ = ["security"]
215
+
216
+
217
+ @configure
218
+ def security() -> SecurityConfig:
219
+ return SecurityConfig(
220
+ providers={"people": ...},
221
+ firewalls={
222
+ "main": FirewallConfig(
223
+ pattern=r"^/(authorize|login)",
224
+ stateless=False,
225
+ provider="people",
226
+ authenticators=(JwtAuthenticatorConfig(),),
227
+ listeners=(PROMPT_LISTENER,),
228
+ ),
229
+ "userinfo": FirewallConfig(
230
+ pattern=r"^/userinfo",
231
+ provider="people",
232
+ authenticators=(OAuth2AuthenticatorConfig(),),
233
+ ),
234
+ },
235
+ )
236
+ ```
237
+
238
+ `PROMPT_LISTENER` is the qualifier of the step that decides `prompt` and `max_age`. Only the
239
+ application knows which firewall signs end-users in, so only it can list the step there — and that
240
+ firewall must keep a session, because the authorization request waits in it while the end-user
241
+ signs in again.
242
+
243
+ ### 4. Sign in
244
+
245
+ ```console
246
+ $ # the client sends the browser to:
247
+ $ # /authorize?response_type=code&client_id=dashboard&redirect_uri=https://dashboard.example.com/callback
248
+ $ # &scope=openid%20profile%20email&state=xyz&nonce=n-0S6_WzA2Mj&code_challenge=…&code_challenge_method=S256
249
+ $ curl -s -u dashboard:s3cret -d grant_type=authorization_code -d code=… \
250
+ -d redirect_uri=https://dashboard.example.com/callback -d code_verifier=… \
251
+ https://authorization.example.com/token
252
+ {"token_type":"Bearer","expires_in":3600,"access_token":"ey…","refresh_token":"def…","id_token":"ey…"}
253
+
254
+ $ curl -s -H "Authorization: Bearer ey…" https://authorization.example.com/userinfo
255
+ {"sub":"alice","name":"Alice","email":"alice@example.com","email_verified":true}
256
+ ```
257
+
258
+ ## Configure
259
+
260
+ `<app>/config/packages/openid_connect_server.py` returns an `OpenIdConnectServerConfig`. Every
261
+ field has a default, so the bundle builds and boots with no configuration at all.
262
+
263
+ | Field | Default | What it does |
264
+ |---|---|---|
265
+ | `identity_provider` | `None` | The application's class implementing `IdentityProviderInterface`. The bundle registers it. Unset, the provider still boots; `/userinfo` reports the setting by name |
266
+ | `claim_sets` | `{}` | The claims a scope of the application's own reveals, by scope — `{"company": ("company_id",)}`. One of the protocol's four scopes is refused |
267
+ | `id_token_ttl` | `None` | How long an ID token lasts. `None` has it expire with the access token answered beside it |
268
+ | `login_path` | `"/login"` | Where an end-user asked to sign in again is sent |
269
+ | `login_firewall` | `"main"` | The firewall signing them in there, whose session keeps the request to return to |
270
+ | `authorize_path` | `"/authorize"` | Where this deployment serves the authorization endpoint, which is where the prompt step acts |
271
+
272
+ The authorization server's own settings decide the rest, in
273
+ `<app>/config/packages/oauth2_server.py`: `authorization_server.issuer` is the `iss` every ID token
274
+ claims (never the request's `Host`), and `authorization_server.signature_algorithm` is what the ID
275
+ token is signed with — the same key, signer and `kid` as an access token. `EdDSA` names no digest
276
+ for the `at_hash`, so the curve of that key decides it — SHA-512 over Ed25519, SHAKE256 over Ed448
277
+ — read off the key the first time it is needed, never while the application builds.
278
+
279
+ Three settings of that server this bundle puts in place itself, and refuses to be undone:
280
+ `enable_auth_code_grant=False` (this package registers the flow answering that grant type),
281
+ `response_type_class=IdTokenResponse`, and `enable_metadata_endpoint` left on. An application
282
+ `@configure` transform runs after a bundle's prepend, so one changing any of them fails the build
283
+ with a message naming the setting.
284
+
285
+ The four claim sets OpenID Connect Core defines ship held and cannot be changed: `profile`,
286
+ `email`, `address` and `phone`. An end-user granting `email` agrees to share what `email` means,
287
+ and a set redefining it would hand a client other claims under the name they agreed to. Add the
288
+ scopes a deployment answers for to `scopes.available`, or `/authorize` refuses a client asking for
289
+ them.
290
+
291
+ ## Endpoints
292
+
293
+ Two route resources, registered by `<app>/config/routes/openid_connect_server.py`:
294
+
295
+ | Resource | Route | Path | Methods |
296
+ |---|---|---|---|
297
+ | `openid_connect_server` | `openid_connect_server_userinfo` | `/userinfo` | `GET`, `POST` |
298
+ | `openid_connect_server_well_known` | `openid_connect_server_configuration` | `/.well-known/openid-configuration` | `GET` |
299
+
300
+ - **`/userinfo`** reads no credential of its own: it answers from the token the request's firewall
301
+ settled on, which is why it fails closed — with no firewall in front of it, there is no token and
302
+ every request is refused. A token that is not one of this server's, or one a client was issued
303
+ for itself, is `401 invalid_token`; one not granted `openid` is `403 insufficient_scope`; each
304
+ carries an RFC 6750 `Bearer` challenge. A success is `200`, `application/json`,
305
+ `cache-control: no-store`, `{"sub": …, **claims}` — `sub` first and never replaced by a claim of
306
+ the identity provider's. A `POST` carries `Content-Type: application/x-www-form-urlencoded`
307
+ (parameters allowed), even with an empty body; any other content type, or none, is
308
+ `400 invalid_request`. The access token is read from the `Authorization` header only.
309
+ - **`/.well-known/openid-configuration`** answers the authorization server's description with the
310
+ provider's fields contributed to it: `userinfo_endpoint` (the issuer plus the path this
311
+ application mounts the endpoint at, so moving it under a prefix moves the published URL),
312
+ `subject_types_supported: ["public"]`, `id_token_signing_alg_values_supported`,
313
+ `claims_supported` (claim names, never scope names), and `claims_parameter_supported`,
314
+ `request_parameter_supported` and `request_uri_parameter_supported`, all `false`. The
315
+ authorization server's own `/.well-known/oauth-authorization-server` carries the same fields: it
316
+ is one description, answered at two paths. The resource is kept apart from the movable one
317
+ because Discovery 1.0 fixes the document at that path under the issuer.
318
+ - **`/authorize`** and **`/token`** stay the authorization server's own endpoints. This package
319
+ changes what happens behind them: the authorization endpoint answers `GET` and a posted form, a
320
+ request asking for `openid` must name its `redirect_uri` (a request without it still may leave it
321
+ out, as OAuth 2.0 allows a client registered with one URI), `prompt=select_account` is refused
322
+ rather than ignored, and the token answer to a sign-in carries the `id_token`. A refresh never
323
+ does.
324
+
325
+ A deployment that keeps the authorization server's `RS256` default publishes `RS256` as its ID
326
+ token signature algorithm, which is what Discovery 1.0 § 3 requires of the document and Core § 15.1
327
+ of the provider. A deployment configured for another `signature_algorithm` publishes only that one:
328
+ the ID token is signed with the authorization server's key, and there is one of those.
329
+
330
+ ## Services
331
+
332
+ `OpenIdConnectServerBundle` registers, under the types an application injects:
333
+
334
+ | Service | Lifetime | What it does |
335
+ |---|---|---|
336
+ | `ClaimExtractor` | shared | Holds every claim set — the protocol's four, then the application's — and picks the claims a token's scopes reveal out of a user's |
337
+ | `IdTokenSettings` | shared | What an ID token is signed with and how long it lasts, read off the authorization server's settings — and, for `EdDSA`, the curve of its key |
338
+ | `IdentityProviderInterface` | shared | The class `identity_provider` names. Unset, a stand-in raises `InvalidConfigurationError` naming the setting the first time a user is needed |
339
+ | `OpenIdAuthCodeGrant` | shared, tagged `GRANT_TAG` | The code flow carrying the sign-in: it seals the `nonce` and the sign-in time into the code, and hands them to the token answer when it is redeemed |
340
+ | `UserInfoController` | per request | Answers `/userinfo`, over the request's own token storage |
341
+ | `OpenIdMetadataContributor` | shared, tagged `METADATA_TAG` | The provider's fields of the description |
342
+ | `OpenIdPromptListener` | per request, qualifier `openid_connect_server#prompt_listener` | The firewall chain step deciding `prompt` and `max_age`, also aliased under `FirewallListenerInterface` |
343
+
344
+ `IdTokenResponse` is not registered here: the authorization server registers whichever class its
345
+ `response_type_class` names, which this bundle made this one. Inject the prompt step by the
346
+ qualifier `PROMPT_LISTENER` carries, never by building it: it takes the request's token storage.
347
+
348
+ The library stands on its own — `ClaimExtractor`, `ClaimSetEntity`, `token_hash`, `parse_prompt`,
349
+ `consent_required`, `IdTokenResponse` and `OpenIdAuthCodeGrant` are plain objects, built from their
350
+ constructors — and the bundle is the integration on top.
351
+
352
+ ## Events
353
+
354
+ This package hears and raises nothing of its own: it uses the authorization server's events.
355
+
356
+ | Name | Listener | Priority |
357
+ |---|---|---|
358
+ | `OAuth2Events.AUTHORIZATION_REQUEST_RESOLVE` | `stamp_auth_time` records on the request when the end-user last signed in, from the newest proof on the firewall's token, and decides nothing | `AUTH_TIME_LISTENER_PRIORITY`, `2048` |
359
+
360
+ A consent listener of the application's runs **below** that priority — the default `0` does —
361
+ because resolving a request stops the listeners after it, and the sign-in time has to be recorded
362
+ first. Read what the client asked for through `OpenIdAuthorizationRequest.get_prompt()`:
363
+
364
+ ```python
365
+ from xtr_event_dispatcher import as_event_listener
366
+ from xtr_security_oauth2_server.event.authorization_request_resolve_event import (
367
+ AuthorizationRequestResolveEvent,
368
+ )
369
+ from xtr_security_oauth2_server.oauth2_events import OAuth2Events
370
+ from xtr_security_openid_connect_server.prompt import consent_required
371
+ from xtr_security_openid_connect_server.request_types.open_id_authorization_request import (
372
+ OpenIdAuthorizationRequest,
373
+ )
374
+
375
+
376
+ @as_event_listener(OAuth2Events.AUTHORIZATION_REQUEST_RESOLVE)
377
+ async def ask_for_consent(event: AuthorizationRequestResolveEvent) -> None:
378
+ request = event.get_authorization_request()
379
+ asked = request.get_prompt() if isinstance(request, OpenIdAuthorizationRequest) else frozenset()
380
+ if "none" in asked and not consent_already_given(event):
381
+ # No page may be shown, so the client is told instead.
382
+ raise consent_required(event)
383
+ if "consent" in asked or not consent_already_given(event):
384
+ _ = event.set_response(await consent_page(event))
385
+ return
386
+ _ = event.resolve_authorization(AuthorizationRequestResolveEvent.APPROVED)
387
+ ```
388
+
389
+ `consent_required(event)` builds the refusal, delivered to the validated redirect URI with the
390
+ `state` echoed back. A remembered sign-in that proved no credential leaves the sign-in time
391
+ unknown: the ID token then states none, and a `max_age` request treats it as stale.
392
+
393
+ ## Use in an application
394
+
395
+ Everything adding this package to an application on
396
+ [xtr-dependency-injection](../xtr-dependency-injection) takes — and, read backwards, what removing
397
+ it undoes.
398
+
399
+ - **Install** — `uv add xtr-security-openid-connect-server`, beside an application already hosting
400
+ [xtr-security-oauth2-server](../xtr-security-oauth2-server).
401
+ - **Recipe** — `uv run xtr-recipes recipes:sync` does the *Activate* and *Configure* steps below,
402
+ and no other: it lists `OpenIdConnectServerBundle`, writes a starting
403
+ `config/packages/openid_connect_server.py` with the login page, login firewall and authorization
404
+ path, and writes `config/routes/openid_connect_server.py` serving the userinfo endpoint at its
405
+ default path and the configuration document at the root. It prints what a recipe cannot make for
406
+ you: the identity provider class, listing the prompt step in the login firewall's `listeners`,
407
+ the firewall in front of `/userinfo`, the scopes, the issuer, and the consent listener.
408
+ - **Activate** — `OpenIdConnectServerBundle: {"all": True}` in `BUNDLES` in
409
+ `<app>/config/bundles.py`, imported from `xtr_security_openid_connect_server.bundle`. The
410
+ authorization server's own `setup(app, kernel)` and `concurrent_scoped_access=True` already
411
+ apply; this package adds no step there.
412
+ - **Brings along** — the [OAuth 2.0 authorization server](../xtr-security-oauth2-server) and,
413
+ through it and on its own account, the [security bundle](../xtr-security), the
414
+ [clock](../xtr-clock), the [event dispatcher](../xtr-event-dispatcher) and the
415
+ [HTTP kernel](../xtr-http-kernel), because this bundle requires every one of them.
416
+ - **Configure** — with no configuration the bundle builds and boots, and answers no claims: a
417
+ `config/packages/openid_connect_server.py` `@configure` function returning an
418
+ `OpenIdConnectServerConfig` names the `identity_provider`, and the login page and firewall a
419
+ re-login goes through — see [Configure](#configure). In
420
+ `<app>/config/packages/security.py`, list `openid_connect_server#prompt_listener` in the
421
+ `listeners` of that firewall and leave it stateful, and put a firewall carrying
422
+ `OAuth2AuthenticatorConfig()` in front of `/userinfo`. In
423
+ `<app>/config/packages/oauth2_server.py`, name the issuer and add the claim scopes this
424
+ deployment answers for to `scopes.available`. A listener on
425
+ `OAuth2Events.AUTHORIZATION_REQUEST_RESOLVE`, below `AUTH_TIME_LISTENER_PRIORITY`, answers the
426
+ consent question — see [Events](#events). `config/routes/openid_connect_server.py` registers both
427
+ route resources; move the userinfo endpoint there, and leave the document at the root — see
428
+ [Endpoints](#endpoints).
429
+ - **Environment** — nothing of its own. The ID token is signed with the authorization server's key,
430
+ so that server's `OAUTH2_SERVER_PRIVATE_KEY_PATH` and its two other variables are all there are.
431
+ - **Ignore** — nothing: this package writes no file into the project.
432
+ - **Remove** — `uv run xtr-recipes recipes:remove xtr-security-openid-connect-server`, then undo
433
+ the steps it printed: the identity provider, the prompt step in the firewall's `listeners`, the
434
+ userinfo firewall and the consent listener are the application's own. The authorization server
435
+ goes back to its shipped code flow and bearer answer by itself, both having been this bundle's
436
+ to prepend.
437
+ - **Check** — `debug:bundles` lists `openid_connect_server` as active, with `oauth2_server` among
438
+ the bundles it requires; `debug:config openid_connect_server` shows the resolved identity
439
+ provider, claim sets, ID-token lifetime, login page and firewall; and
440
+ `GET /.well-known/openid-configuration` answers with this provider's description, naming the
441
+ issuer and the userinfo endpoint.
442
+
443
+ ## Kernel / bundle
444
+
445
+ ```python
446
+ # app/config/bundles.py
447
+ from xtr_security_openid_connect_server.bundle import OpenIdConnectServerBundle
448
+
449
+ BUNDLES = {OpenIdConnectServerBundle: {"all": True}}
450
+ ```
451
+
452
+ `OpenIdConnectServerBundle` (name `openid_connect_server`, config `OpenIdConnectServerConfig`)
453
+ requires the [OAuth 2.0 authorization server](../xtr-security-oauth2-server),
454
+ [security](../xtr-security), [clock](../xtr-clock), [event dispatcher](../xtr-event-dispatcher) and
455
+ [HTTP kernel](../xtr-http-kernel) bundles, so listing it alone brings every one of them along. No
456
+ other bundle requires it, so it only arrives by being listed. It:
457
+
458
+ - prepends onto the authorization server's configuration the flow and the answer a sign-in takes —
459
+ `enable_auth_code_grant=False`, `response_type_class=IdTokenResponse` — and appends `openid` to
460
+ `scopes.available` when that tuple names anything, leaving an empty one empty so the
461
+ authorization server's own error about it still fires;
462
+ - registers the claim sets, the ID-token settings, the identity provider, the flow tagged
463
+ `GRANT_TAG`, the userinfo controller and its route resources, the metadata contributor tagged
464
+ `METADATA_TAG`, and the prompt step under `openid_connect_server#prompt_listener`;
465
+ - loads the listener recording the sign-in time on the authorization server's consent event;
466
+ - fails the build when the resolved authorization server runs the shipped redirect flow, answers
467
+ with something that cannot carry an ID token, or does not describe itself — each named, because
468
+ an application transform runs after this bundle's prepend and can undo it.
469
+
470
+ The authorization server knows nothing of this bundle: everything above goes through seams it
471
+ publishes for an add-on. It reads no key, user or issuer while building, so it builds and boots
472
+ with no configuration.
473
+
474
+ ## Errors
475
+
476
+ Everything this package raises derives from `OpenIdConnectServerError`, itself an `OAuth2Error`
477
+ (`xtr_security_oauth2_server_core.exception`), so one `except OAuth2Error` still catches the
478
+ authorization server and its identity layer together:
479
+
480
+ | Error | Base | Raised when |
481
+ |---|---|---|
482
+ | `OpenIdConnectServerError` | `OAuth2Error` | The base of them all; nothing raises it directly |
483
+ | `InvalidConfigurationError` | `OpenIdConnectServerError`, `ValueError` | A setting the provider cannot run with, or one missing where a service needs it — `identity_provider` unset, a login firewall that keeps no session — naming it and `<app>/config/packages/openid_connect_server.py` |
484
+ | `InvalidArgumentError` | `OpenIdConnectServerError`, `ValueError` | One of this package's objects used wrongly: a claim set naming no scope, a negative `max_age`, a claim set for a scope the protocol defines, an unsigned ID token back from the signature provider |
485
+ | `UnsupportedAlgorithmError` | `OpenIdConnectServerError` | A signature algorithm whose digest its name does not say, or an `EdDSA` key on neither Ed25519 nor Ed448, so no `at_hash` can be taken for it |
486
+
487
+ A refusal a client is owed is never one of these: it is the authorization server's `OAuthServerError`,
488
+ which the endpoints deliver. `parse_prompt` raises it as `invalid_request`, `login_required` and
489
+ `consent_required` build it for the two refusals a sign-in can end in, and the flow raises
490
+ `server_error` when the token answer cannot carry an ID token.
491
+
492
+ ## Layout
493
+
494
+ ```
495
+ xtr_security_openid_connect_server/
496
+ ├── _endpoint_paths.py where each endpoint lives by default, and its route name
497
+ ├── routing/ the endpoints, and the two route resources routes files register
498
+ ├── controller/ UserInfoController
499
+ ├── grant/ OpenIdAuthCodeGrant
500
+ ├── request_types/ OpenIdAuthorizationRequest: nonce, prompt, max_age, auth_time
501
+ ├── response_types/ IdTokenResponse
502
+ ├── firewall/ OpenIdPromptListener
503
+ ├── event_listener/ the listener recording the sign-in time, and its priority
504
+ ├── repositories/ IdentityProviderInterface
505
+ ├── entities/ the claim set, and what a user with claims answers to
506
+ ├── claim_extractor.py the claim sets, and picking a user's claims through them
507
+ ├── id_token_settings.py what an ID token is signed with, and how long it lasts
508
+ ├── token_hash.py at_hash
509
+ ├── prompt.py parse_prompt, login_required, consent_required
510
+ ├── open_id_metadata_contributor.py the provider's fields of the description
511
+ ├── exception/ OpenIdConnectServerError and the three under it
512
+ ├── recipe/ what recipes:sync applies
513
+ └── bundle/ OpenIdConnectServerBundle, its configuration and PROMPT_LISTENER
514
+ ```
515
+
516
+ ## Development
517
+
518
+ Developed in the [python-xtr](https://github.com/xterr/python-xtr) monorepo, under
519
+ `packages/xtr-security-openid-connect-server`; run the commands below from there. The conformance
520
+ suite, one module per part of the protocol, is under
521
+ [`tests/conformance/`](tests/conformance/README.md).
522
+
523
+ ```sh
524
+ uv sync --all-packages --all-extras
525
+ uv run ruff check && uv run ruff format --check && uv run basedpyright && uv run ty check && uv run pytest --cov
526
+ ```
527
+
528
+ ## License
529
+
530
+ MIT — see [LICENSE](LICENSE).