xtr-security-openid-connect-server 6.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. xtr_security_openid_connect_server/.agents/skills/xtr-security-openid-connect-server/SKILL.md +332 -0
  2. xtr_security_openid_connect_server/.agents/skills/xtr-security-openid-connect-server/references/configuration.md +95 -0
  3. xtr_security_openid_connect_server/.agents/skills/xtr-security-openid-connect-server/references/flows.md +99 -0
  4. xtr_security_openid_connect_server/__init__.py +21 -0
  5. xtr_security_openid_connect_server/_endpoint_paths.py +32 -0
  6. xtr_security_openid_connect_server/bundle/__init__.py +17 -0
  7. xtr_security_openid_connect_server/bundle/_service_factories.py +188 -0
  8. xtr_security_openid_connect_server/bundle/openid_connect_server_bundle.py +230 -0
  9. xtr_security_openid_connect_server/bundle/openid_connect_server_config.py +98 -0
  10. xtr_security_openid_connect_server/claim_extractor.py +152 -0
  11. xtr_security_openid_connect_server/controller/__init__.py +11 -0
  12. xtr_security_openid_connect_server/controller/user_info_controller.py +171 -0
  13. xtr_security_openid_connect_server/entities/__init__.py +26 -0
  14. xtr_security_openid_connect_server/entities/claim_set_entity.py +62 -0
  15. xtr_security_openid_connect_server/entities/claim_set_entity_interface.py +28 -0
  16. xtr_security_openid_connect_server/entities/claim_set_interface.py +38 -0
  17. xtr_security_openid_connect_server/entities/user_claims_entity_interface.py +28 -0
  18. xtr_security_openid_connect_server/event_listener/__init__.py +12 -0
  19. xtr_security_openid_connect_server/event_listener/auth_time_listener.py +52 -0
  20. xtr_security_openid_connect_server/exception/__init__.py +29 -0
  21. xtr_security_openid_connect_server/exception/invalid_argument_error.py +24 -0
  22. xtr_security_openid_connect_server/exception/invalid_configuration_error.py +28 -0
  23. xtr_security_openid_connect_server/exception/open_id_connect_server_error.py +21 -0
  24. xtr_security_openid_connect_server/exception/unsupported_algorithm_error.py +47 -0
  25. xtr_security_openid_connect_server/firewall/__init__.py +12 -0
  26. xtr_security_openid_connect_server/firewall/open_id_prompt_listener.py +287 -0
  27. xtr_security_openid_connect_server/grant/__init__.py +13 -0
  28. xtr_security_openid_connect_server/grant/open_id_auth_code_grant.py +242 -0
  29. xtr_security_openid_connect_server/id_token_settings.py +63 -0
  30. xtr_security_openid_connect_server/open_id_metadata_contributor.py +112 -0
  31. xtr_security_openid_connect_server/prompt.py +126 -0
  32. xtr_security_openid_connect_server/py.typed +0 -0
  33. xtr_security_openid_connect_server/recipe/__init__.py +14 -0
  34. xtr_security_openid_connect_server/recipe/files/config/openid_connect_server.py.tmpl +46 -0
  35. xtr_security_openid_connect_server/recipe/files/config/routes/openid_connect_server.py.tmpl +37 -0
  36. xtr_security_openid_connect_server/recipe/manifest.toml +36 -0
  37. xtr_security_openid_connect_server/repositories/__init__.py +13 -0
  38. xtr_security_openid_connect_server/repositories/identity_provider_interface.py +55 -0
  39. xtr_security_openid_connect_server/request_types/__init__.py +15 -0
  40. xtr_security_openid_connect_server/request_types/open_id_authorization_request.py +88 -0
  41. xtr_security_openid_connect_server/response_types/__init__.py +12 -0
  42. xtr_security_openid_connect_server/response_types/id_token_response.py +161 -0
  43. xtr_security_openid_connect_server/routing/__init__.py +28 -0
  44. xtr_security_openid_connect_server/routing/_endpoints.py +103 -0
  45. xtr_security_openid_connect_server/token_hash.py +84 -0
  46. xtr_security_openid_connect_server-6.3.0.dist-info/METADATA +530 -0
  47. xtr_security_openid_connect_server-6.3.0.dist-info/RECORD +50 -0
  48. xtr_security_openid_connect_server-6.3.0.dist-info/WHEEL +4 -0
  49. xtr_security_openid_connect_server-6.3.0.dist-info/entry_points.txt +6 -0
  50. xtr_security_openid_connect_server-6.3.0.dist-info/licenses/LICENSE +22 -0
@@ -0,0 +1,332 @@
1
+ ---
2
+ name: xtr-security-openid-connect-server
3
+ description: How to turn an xtr-security-oauth2-server deployment into an OpenID Connect provider for the authorization code flow with xtr-security-openid-connect-server - ID tokens with nonce, auth_time and at_hash, a /userinfo endpoint, the /.well-known/openid-configuration document, and prompt and max_age handling. Use when an application must offer "sign in with" to its OAuth clients, issue or debug an id_token, answer claims for a signed-in end user, write an IdentityProviderInterface, add the claims a scope reveals, decide prompt=none, prompt=login, prompt=consent or max_age, send an end user back through the login page and return to the authorization request, publish or read the OpenID configuration document, raise consent_required from a consent listener, or fix an InvalidConfigurationError naming identity_provider, login_firewall or a claim set.
4
+ ---
5
+
6
+ # xtr-security-openid-connect-server
7
+
8
+ An add-on bundle on [xtr-security-oauth2-server](https://github.com/xterr/python-xtr/tree/main/packages/xtr-security-oauth2-server):
9
+ it makes that deployment's authorization server an OpenID Connect provider for the authorization
10
+ code flow. A client asking for the `openid` scope is answered an `id_token` naming the end-user,
11
+ reads that end-user's claims from `/userinfo`, and discovers both from
12
+ `/.well-known/openid-configuration`.
13
+
14
+ The authorization server knows nothing of this package: the flows, clients, endpoints, consent
15
+ event and `oauth2` firewall key are the `xtr-security-oauth2-server` skill's; firewalls, voters and
16
+ user providers are the `xtr-security` skill's. This skill covers only the identity layer on top.
17
+
18
+ No logout, no implicit or hybrid flow, no pairwise subjects, no `claims` parameter, no request
19
+ objects, no dynamic client registration, no `id_token` on a refresh.
20
+
21
+ ## Quick reference
22
+
23
+ - List `OpenIdConnectServerBundle` beside `OAuth2ServerBundle`; it requires that bundle, so listing
24
+ it alone brings it along. It puts `enable_auth_code_grant=False` and
25
+ `response_type_class=IdTokenResponse` in place, and appends `openid` to `scopes.available` when
26
+ that tuple names anything.
27
+ - Write a class implementing `IdentityProviderInterface` (async
28
+ `get_user_entity_by_identifier(identifier, client)`) and name it in `identity_provider`. The
29
+ bundle registers it; never register it again.
30
+ - List `PROMPT_LISTENER` (`"openid_connect_server#prompt_listener"`, from
31
+ `xtr_security_openid_connect_server.bundle`) in the `listeners` of the firewall that signs
32
+ end-users in, and leave that firewall stateful.
33
+ - Put a firewall carrying `OAuth2AuthenticatorConfig()` in front of `/userinfo`. The endpoint reads
34
+ no credential of its own, so without one every request is refused.
35
+ - Two route resources: `openid_connect_server` (`/userinfo`, `GET` and `POST`) moves under a
36
+ prefix; `openid_connect_server_well_known` (`/.well-known/openid-configuration`) stays at the
37
+ root.
38
+ - The ID token claims `iss`, `sub`, `aud`, `exp`, `iat`, `auth_time`, `nonce` and `at_hash` — no
39
+ scope claims. `profile`, `email`, `address` and `phone` are answered by `/userinfo` only.
40
+ - The ID token is signed with the authorization server's key, `kid` and
41
+ `authorization_server.signature_algorithm`, so the published key set verifies it.
42
+ - A consent listener runs below `AUTH_TIME_LISTENER_PRIORITY` (`2048`) and raises
43
+ `consent_required(event)` when `prompt=none` needs a consent not yet given.
44
+ - Every error derives from `OpenIdConnectServerError`, itself an `OAuth2Error`.
45
+
46
+ ## Configure the provider
47
+
48
+ ```python
49
+ # app/config/packages/openid_connect_server.py
50
+ from xtr_dependency_injection import configure
51
+ from xtr_security_openid_connect_server.bundle import OpenIdConnectServerConfig
52
+
53
+ from app.identity_provider import PeopleIdentityProvider
54
+
55
+ __all__ = ["openid_connect_server"]
56
+
57
+
58
+ @configure
59
+ def openid_connect_server() -> OpenIdConnectServerConfig:
60
+ return OpenIdConnectServerConfig(
61
+ identity_provider=PeopleIdentityProvider,
62
+ claim_sets={"company": ("company_id",)},
63
+ login_path="/login",
64
+ login_firewall="main",
65
+ )
66
+ ```
67
+
68
+ - Every field has a default, so the bundle builds and boots with nothing written. `/userinfo` is
69
+ what reports an unset `identity_provider`, by name and with this file.
70
+ - `claim_sets` adds the claims a scope of the application's own reveals. The four scopes OpenID
71
+ Connect Core defines come held and are refused here.
72
+ - Add the claim scopes the deployment answers for to `scopes.available` in
73
+ `app/config/packages/oauth2_server.py`, or `/authorize` refuses a client asking for them.
74
+
75
+ Every field, default and refusal:
76
+ [references/configuration.md](references/configuration.md).
77
+
78
+ ## Say where the end-users are
79
+
80
+ ```python
81
+ # app/identity_provider.py
82
+ from __future__ import annotations
83
+
84
+ from typing import TYPE_CHECKING, final
85
+
86
+ from xtr_security_core.exception import UserNotFoundError
87
+
88
+ if TYPE_CHECKING:
89
+ from collections.abc import Mapping
90
+
91
+ from xtr_security_oauth2_server_core.entities.client_entity_interface import (
92
+ ClientEntityInterface,
93
+ )
94
+
95
+ from xtr_security_openid_connect_server.entities import UserClaimsEntityInterface
96
+
97
+
98
+ @final
99
+ class Person:
100
+ def __init__(self, identifier: str, claims: Mapping[str, object]) -> None:
101
+ self._identifier = identifier
102
+ self._claims = claims
103
+
104
+ def get_identifier(self) -> str:
105
+ return self._identifier
106
+
107
+ async def get_claims(self) -> Mapping[str, object]:
108
+ return self._claims
109
+
110
+
111
+ @final
112
+ class PeopleIdentityProvider:
113
+ async def get_user_entity_by_identifier(
114
+ self,
115
+ identifier: str,
116
+ client: ClientEntityInterface,
117
+ ) -> UserClaimsEntityInterface:
118
+ del client # Describe a person differently per client here, if a deployment must.
119
+ if identifier != "alice":
120
+ raise UserNotFoundError(identifier)
121
+ return Person("alice", {"name": "Alice", "email": "alice@example.com"})
122
+ ```
123
+
124
+ - The identifier comes from a token this server issued, so a missing user raises
125
+ `UserNotFoundError` rather than answering `None` — the error the security family's user providers
126
+ already raise, so an implementation built on one lets it through unchanged.
127
+ - Claims the granted scopes do not reveal are dropped by `ClaimExtractor`, so returning everything
128
+ a person holds is safe.
129
+ - The container builds the class from its constructor: declare what it needs as typed parameters.
130
+
131
+ ## Serve and guard the endpoints
132
+
133
+ ```python
134
+ # app/config/routes/openid_connect_server.py
135
+ from __future__ import annotations
136
+
137
+ from typing import TYPE_CHECKING
138
+
139
+ from xtr_http_kernel.routing.decorator import as_routes
140
+
141
+ if TYPE_CHECKING:
142
+ from collections.abc import Mapping
143
+
144
+ from fastapi import APIRouter, FastAPI
145
+
146
+ __all__ = ["openid_connect_server"]
147
+
148
+
149
+ @as_routes
150
+ def openid_connect_server(app: FastAPI, resources: Mapping[str, APIRouter]) -> None:
151
+ app.include_router(resources["openid_connect_server"])
152
+ app.include_router(resources["openid_connect_server_well_known"])
153
+ ```
154
+
155
+ ```python
156
+ # app/config/packages/security.py — the two firewalls this package needs
157
+ from xtr_security.bundle import FirewallConfig
158
+ from xtr_security_oauth2_server.bundle import OAuth2AuthenticatorConfig
159
+ from xtr_security_openid_connect_server.bundle import PROMPT_LISTENER
160
+
161
+ main = FirewallConfig(
162
+ pattern=r"^/(authorize|login)",
163
+ stateless=False,
164
+ provider="people",
165
+ authenticators=(...,), # whatever signs an end-user in
166
+ listeners=(PROMPT_LISTENER,),
167
+ )
168
+ userinfo = FirewallConfig(
169
+ pattern=r"^/userinfo",
170
+ provider="people",
171
+ authenticators=(OAuth2AuthenticatorConfig(),),
172
+ )
173
+ ```
174
+
175
+ - The prompt step must sit on the firewall that signs end-users in, the one `login_firewall` names:
176
+ only that firewall covers both the authorization endpoint and the login page, and the
177
+ authorization request waits in its session during a re-login. A stateless one is refused by name
178
+ the first time a re-login is asked of it.
179
+ - Moving the userinfo endpoint under a prefix moves the `userinfo_endpoint` the document publishes;
180
+ move the firewall pattern with it.
181
+
182
+ ## Decide prompt, max_age and consent
183
+
184
+ ```python
185
+ from xtr_event_dispatcher import as_event_listener
186
+ from xtr_security_oauth2_server.event.authorization_request_resolve_event import (
187
+ AuthorizationRequestResolveEvent,
188
+ )
189
+ from xtr_security_oauth2_server.oauth2_events import OAuth2Events
190
+ from xtr_security_openid_connect_server.prompt import consent_required
191
+ from xtr_security_openid_connect_server.request_types.open_id_authorization_request import (
192
+ OpenIdAuthorizationRequest,
193
+ )
194
+
195
+
196
+ @as_event_listener(OAuth2Events.AUTHORIZATION_REQUEST_RESOLVE) # priority 0, below 2048
197
+ async def ask_for_consent(event: AuthorizationRequestResolveEvent) -> None:
198
+ request = event.get_authorization_request()
199
+ asked = request.get_prompt() if isinstance(request, OpenIdAuthorizationRequest) else frozenset()
200
+ if "none" in asked and not consent_already_given(event):
201
+ raise consent_required(event)
202
+ if "consent" in asked or not consent_already_given(event):
203
+ _ = event.set_response(await consent_page(event))
204
+ return
205
+ _ = event.resolve_authorization(AuthorizationRequestResolveEvent.APPROVED)
206
+ ```
207
+
208
+ - `prompt=none` with nobody signed in, or a sign-in older than `max_age`, is answered
209
+ `login_required` at the validated redirect URI with the `state` echoed — decided by the prompt
210
+ step, before the endpoint refuses an anonymous request.
211
+ - `prompt=login`, or an exceeded `max_age`, parks the request in the session and redirects to
212
+ `login_path`; the return trip carries the same request with `login` taken out of `prompt`.
213
+ - A remembered sign-in proves no credential, so the sign-in time is unknown: the ID token states no
214
+ `auth_time`, and any `max_age` treats it as stale.
215
+ - `prompt=select_account` and an unknown value are `invalid_request`, never ignored.
216
+
217
+ What each parameter does to a round trip, and what the ID token then carries:
218
+ [references/flows.md](references/flows.md).
219
+
220
+ ## Testing
221
+
222
+ Serve the application over ASGI inside its own lifespan, both bundles listed, with
223
+ `InMemoryPersistenceConfig()` so each kernel starts empty. Nothing is mocked.
224
+
225
+ ```python
226
+ import httpx
227
+ import pytest
228
+ from xtr_dependency_injection.testing import assert_zero_config
229
+ from xtr_security_openid_connect_server.bundle import OpenIdConnectServerBundle
230
+
231
+ pytestmark = pytest.mark.anyio
232
+
233
+
234
+ @pytest.fixture
235
+ def anyio_backend() -> str:
236
+ return "asyncio"
237
+
238
+
239
+ async def test_the_bundle_boots_unconfigured() -> None:
240
+ await assert_zero_config(OpenIdConnectServerBundle)
241
+
242
+
243
+ async def test_a_sign_in_is_answered_an_id_token(app) -> None: # app calls setup(app, kernel)
244
+ async with app.router.lifespan_context(app):
245
+ transport = httpx.ASGITransport(app=app)
246
+ async with httpx.AsyncClient(transport=transport, base_url="https://test") as http:
247
+ answered = await http.post(
248
+ "/token",
249
+ data={
250
+ "grant_type": "authorization_code",
251
+ "code": "…",
252
+ "redirect_uri": "https://dashboard.example.com/callback",
253
+ "code_verifier": "…",
254
+ },
255
+ auth=("dashboard", "s3cret"),
256
+ )
257
+
258
+ assert "id_token" in answered.json()
259
+ ```
260
+
261
+ - Check the `id_token` with a client implementation, not by re-signing it: the provider's own suite
262
+ reads it with `xtr-security-http`'s OpenID Connect client, which shares no code with it.
263
+ - `ClaimExtractor`, `ClaimSetEntity`, `token_hash`, `parse_prompt`, `consent_required`,
264
+ `IdTokenResponse` and `OpenIdAuthCodeGrant` are plain objects: test them with no kernel.
265
+ - Freeze time with `MockClock` from [xtr-clock](https://github.com/xterr/python-xtr/tree/main/packages/xtr-clock)
266
+ wherever `auth_time`, `max_age` or an expiry is asserted.
267
+
268
+ ## Use in an application
269
+
270
+ 1. **Install** — `uv add xtr-security-openid-connect-server`, beside an application already hosting
271
+ `xtr-security-oauth2-server`.
272
+ 2. **Recipe** — `uv run xtr-recipes recipes:sync` lists `OpenIdConnectServerBundle`, writes a
273
+ starting `config/packages/openid_connect_server.py` and a
274
+ `config/routes/openid_connect_server.py` serving the userinfo endpoint at its default path and
275
+ the document at the root. It prints the rest: the identity provider, the prompt step, the
276
+ userinfo firewall, the scopes, the issuer, the consent listener.
277
+ 3. **Activate** — `OpenIdConnectServerBundle: {"all": True}` in `BUNDLES` in
278
+ `<app>/config/bundles.py`. The authorization server's `setup(app, kernel)` and
279
+ `concurrent_scoped_access=True` already apply.
280
+ 4. **Brings along** — the OAuth 2.0 authorization server, and the security, clock, event-dispatcher
281
+ and HTTP-kernel bundles, all required.
282
+ 5. **Configure** — `identity_provider`, `login_path` and `login_firewall` in
283
+ `config/packages/openid_connect_server.py`; `openid_connect_server#prompt_listener` in that
284
+ firewall's `listeners`, a firewall over `/userinfo`, in
285
+ `config/packages/security.py`; the issuer and the claim scopes in
286
+ `config/packages/oauth2_server.py`; a consent listener below `AUTH_TIME_LISTENER_PRIORITY`.
287
+ 6. **Environment** — nothing of its own: the ID token is signed with the authorization server's
288
+ key, so that server's variables are all there are.
289
+ 7. **Ignore** — nothing: this package writes no file into the project.
290
+ 8. **Remove** — `uv run xtr-recipes recipes:remove xtr-security-openid-connect-server`, then undo
291
+ the printed steps. The authorization server goes back to its shipped code flow and bearer answer
292
+ by itself.
293
+ 9. **Check** — `debug:bundles` shows `openid_connect_server` active with `oauth2_server` required;
294
+ `debug:config openid_connect_server`; `GET /.well-known/openid-configuration` names the issuer
295
+ and the userinfo endpoint.
296
+
297
+ ## Errors
298
+
299
+ All derive from `OpenIdConnectServerError`, itself an `OAuth2Error`.
300
+
301
+ | Error | Raised when |
302
+ | --- | --- |
303
+ | `InvalidConfigurationError` | A setting the provider cannot run with, or one missing where a service needs it — `identity_provider` unset, a stateless `login_firewall`, an authorization server whose flow or answer was changed back — named, with the file to write it in |
304
+ | `InvalidArgumentError` | One of this package's objects used wrongly: a claim set naming no scope or one the protocol defines, a negative `max_age`, an unsigned ID token back from the signature provider |
305
+ | `UnsupportedAlgorithmError` | 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 |
306
+
307
+ A refusal a client is owed is the authorization server's `OAuthServerError`, which the endpoints
308
+ deliver: `invalid_request` from `parse_prompt` and a malformed `max_age` or missing `redirect_uri`,
309
+ `login_required` and `consent_required` for the two refusals a sign-in can end in, and
310
+ `server_error` when the token answer cannot carry an ID token.
311
+
312
+ ## Do not
313
+
314
+ - Do not register the `identity_provider` class yourself, or alias it under
315
+ `IdentityProviderInterface`: the bundle does both from the setting.
316
+ - Do not serve `/userinfo` without a firewall carrying `OAuth2AuthenticatorConfig()`, and do not
317
+ send its token in the request body: the header is the only place it is read.
318
+ - Do not list the prompt step on a stateless firewall, or on one that does not cover the login page
319
+ as well as the authorization endpoint.
320
+ - Do not change `enable_auth_code_grant`, `response_type_class` or `enable_metadata_endpoint` in an
321
+ application `@configure`: a transform runs after this bundle's prepend, and the build is failed
322
+ by name.
323
+ - Do not expect scope claims in the ID token — `name`, `email`, `address`, `phone_number` are
324
+ `/userinfo`'s — and do not expect an `id_token` from a refresh.
325
+ - Do not put a claim set under `profile`, `email`, `address` or `phone`: an end-user who granted one
326
+ agreed to what the protocol says it reveals.
327
+ - Do not register a consent listener at or above `AUTH_TIME_LISTENER_PRIORITY`: resolving a request
328
+ stops the listeners after it, and the sign-in time would never be recorded.
329
+ - Do not try to move `/.well-known/openid-configuration` under a prefix: a client reads it at that
330
+ path under the issuer.
331
+ - Do not claim certification. The OpenID Foundation suite is an optional manual run, described in
332
+ the package's `tests/conformance/README.md`, and its outcome is nothing this package states.
@@ -0,0 +1,95 @@
1
+ # Configuration
2
+
3
+ Everything the OpenID Connect provider reads, where it is written, and what it refuses.
4
+
5
+ ## `OpenIdConnectServerConfig`
6
+
7
+ Written in `<app>/config/packages/openid_connect_server.py`, returned from a `@configure`
8
+ function. Frozen, and buildable with no arguments: the bundle builds and boots on these defaults.
9
+
10
+ | Field | Type | Default | What it does |
11
+ |---|---|---|---|
12
+ | `identity_provider` | `type[IdentityProviderInterface] \| None` | `None` | The application's class that finds a signed-in end-user and the claims they hold. The bundle registers it and aliases it under `IdentityProviderInterface` |
13
+ | `claim_sets` | `Mapping[str, tuple[str, ...]]` | `{}` | The claims a scope of the application's own reveals, by scope |
14
+ | `id_token_ttl` | `timedelta \| None` | `None` | How long an ID token lasts. `None` has it expire with the access token answered beside it |
15
+ | `login_path` | `str` | `"/login"` | Where an end-user asked to sign in again is sent |
16
+ | `login_firewall` | `str` | `"main"` | The firewall signing them in there, whose session keeps the request to return to |
17
+ | `authorize_path` | `str` | `"/authorize"` | Where this deployment serves the authorization endpoint, which is where the prompt step acts |
18
+
19
+ ### What it refuses, at build
20
+
21
+ `__post_init__` raises `InvalidConfigurationError`, naming the setting and this file, when:
22
+
23
+ - `login_path`, `login_firewall` or `authorize_path` is the empty string;
24
+ - `id_token_ttl` is set and not positive, which would mint ID tokens already expired;
25
+ - `claim_sets` holds a set for the empty scope;
26
+ - `claim_sets` holds a set for `profile`, `email`, `address` or `phone` — the four scopes OpenID
27
+ Connect Core defines together with the claims they reveal. Give those claims a scope of the
28
+ application's own instead.
29
+
30
+ ### What it reports later, by name
31
+
32
+ - `identity_provider` unset: the provider still builds, boots and runs the console. The first
33
+ request to `/userinfo` raises `InvalidConfigurationError` naming the setting and the file.
34
+ - A stateless `login_firewall`: raised the first time a re-login is asked of it, naming
35
+ `stateless`, the firewall, `<app>/config/packages/security.py` and `login_firewall`.
36
+
37
+ ## The authorization server's settings this package reads
38
+
39
+ Written in `<app>/config/packages/oauth2_server.py`, on `AuthorizationServerConfig`.
40
+
41
+ | Setting | Read for |
42
+ |---|---|
43
+ | `issuer` | The `iss` every ID token claims, and the origin the document's `userinfo_endpoint` is built on. Never the request's `Host`. Unconfigured, `userinfo_endpoint` is left out |
44
+ | `signature_algorithm` | The ID token's `alg`, the digest `at_hash` is taken with (for `EdDSA`, the one the server key's curve signs with: SHA-512 over Ed25519, SHAKE256 over Ed448), and the one entry in `id_token_signing_alg_values_supported` |
45
+ | `auth_code_ttl`, `refresh_token_ttl`, `require_code_challenge_for_public_clients` | Handed to this package's flow, which the server hands no settings of its own |
46
+ | `scopes.available` | `openid` is appended when the tuple names anything and lacks it. An empty tuple stays empty, so the authorization server's own error about it still fires |
47
+
48
+ ## The three settings this bundle puts in place
49
+
50
+ `prepend_extension` sets them; `process` fails the build when the resolved configuration does not
51
+ carry them, because an application `@configure` transform runs after a bundle's prepend.
52
+
53
+ | Setting | Made | Why |
54
+ |---|---|---|
55
+ | `enable_auth_code_grant` | `False` | This package registers the flow answering `authorization_code`; two grants for one type are refused |
56
+ | `response_type_class` | `IdTokenResponse` | The plain bearer answer cannot carry an `id_token`. A subclass of it is accepted |
57
+ | `enable_metadata_endpoint` | left `True` | Without the description there is nothing for a client to discover the provider from |
58
+
59
+ Each refusal names the setting, says this bundle set it, and points at
60
+ `<app>/config/packages/oauth2_server.py`.
61
+
62
+ ## Claim sets
63
+
64
+ `ClaimExtractor` holds the protocol's four, then whatever `claim_sets` adds, in the order given.
65
+ `extract(scopes, claims)` keeps only the names a granted scope's set holds **and** the user holds;
66
+ a later scope wins a name two sets both hold.
67
+
68
+ | Scope | Claims it reveals |
69
+ |---|---|
70
+ | `profile` | `name`, `family_name`, `given_name`, `middle_name`, `nickname`, `preferred_username`, `profile`, `picture`, `website`, `gender`, `birthdate`, `zoneinfo`, `locale`, `updated_at` |
71
+ | `email` | `email`, `email_verified` |
72
+ | `address` | `address` |
73
+ | `phone` | `phone_number`, `phone_number_verified` |
74
+
75
+ `openid` itself has no set, and reveals nothing. Build sets by hand with
76
+ `ClaimSetEntity(scope, claim_names)`, or bring a class of your own meeting
77
+ `ClaimSetEntityInterface`.
78
+
79
+ ## The published document
80
+
81
+ `OpenIdMetadataContributor` adds these to the authorization server's description, answered both at
82
+ `/.well-known/openid-configuration` and at the server's own
83
+ `/.well-known/oauth-authorization-server`.
84
+
85
+ | Field | Value |
86
+ |---|---|
87
+ | `userinfo_endpoint` | `issuer` plus the path this application mounts the endpoint at, read on every call, so a routes file that moved it publishes where it now is. Left out with no issuer, or outside a served request |
88
+ | `subject_types_supported` | `["public"]` |
89
+ | `id_token_signing_alg_values_supported` | `[signature_algorithm]` — one entry, because there is one key |
90
+ | `claims_supported` | Sorted: `sub`, `iss`, `aud`, `exp`, `iat`, `auth_time`, `nonce`, and every claim **name** of every claim set. Never a scope name |
91
+ | `claims_parameter_supported`, `request_parameter_supported`, `request_uri_parameter_supported` | `False` |
92
+
93
+ A deployment on the authorization server's `RS256` default publishes `RS256`, which is what
94
+ Discovery 1.0 § 3 asks of the document. One configured for another algorithm publishes only that
95
+ one.
@@ -0,0 +1,99 @@
1
+ # Flows
2
+
3
+ What a sign-in does end to end, and what each request parameter changes.
4
+
5
+ ## The authorization code flow, with `openid`
6
+
7
+ 1. The client sends the browser to `authorize_path` with `response_type=code`, `client_id`,
8
+ `redirect_uri`, `scope` containing `openid`, and usually `state`, `nonce`, `code_challenge` and
9
+ `code_challenge_method`. `GET` and a form-encoded `POST` both work.
10
+ 2. The login firewall's chain runs. The prompt step acts there when the request carries `prompt` or
11
+ `max_age`, and nothing else does.
12
+ 3. `OpenIdAuthCodeGrant.validate_authorization_request` reads the shipped request first — an
13
+ unknown client or an unregistered redirect is refused as it always was — then `nonce`, `prompt`
14
+ and `max_age`. A request asking for `openid` and naming no `redirect_uri` is `invalid_request`,
15
+ answered as a document, because it named nowhere to send a refusal.
16
+ 4. `OAuth2Events.AUTHORIZATION_REQUEST_RESOLVE` is dispatched. `stamp_auth_time` records the
17
+ sign-in time at priority `2048`; the application's consent listener, below it, approves, denies
18
+ or answers a page.
19
+ 5. The code is minted with the `nonce` and the sign-in time sealed into it beside what the shipped
20
+ flow seals, and the browser goes back to the redirect URI.
21
+ 6. The client redeems the code at `/token`. The shipped half runs first, so an unreadable, expired,
22
+ spent or another client's code is refused in the protocol's own words. Then the sign-in is read
23
+ back out of the code and handed to the answer.
24
+ 7. The answer is the bearer answer plus `id_token`, signed with the authorization server's key and
25
+ `kid`.
26
+
27
+ ## What the ID token carries
28
+
29
+ | Claim | From |
30
+ |---|---|
31
+ | `iss` | `authorization_server.issuer` |
32
+ | `sub` | The resource owner's identifier, as the code named it |
33
+ | `aud` | The client's identifier, as a string |
34
+ | `iat` | The clock, when the answer is built |
35
+ | `exp` | `iat + id_token_ttl`, or the access token's own expiry when that setting is unset |
36
+ | `auth_time` | The newest authentication proof on the firewall's token at the authorization endpoint. Omitted when unknown |
37
+ | `nonce` | The `nonce` the authorization request carried. Omitted when it carried none |
38
+ | `at_hash` | The left half of the digest of the access token string answered beside it, base64url without padding, under the digest the `alg` signs with — for `EdDSA`, the one its key's curve signs with: SHA-512 over Ed25519, 114 octets of SHAKE256 over Ed448 |
39
+
40
+ Nothing else. `name`, `email`, `address` and `phone_number` are `/userinfo`'s, never the ID token's.
41
+
42
+ An ID token is answered only when the grant handed the answer an identity **and** the granted
43
+ scopes contain `openid`. The refresh grant hands none, so a refresh answer never carries one.
44
+
45
+ ## `prompt`
46
+
47
+ Read as a space-separated set. A value this server does not act on, and `none` beside any other
48
+ value, are `invalid_request` — refused rather than ignored, so a client never carries on believing
49
+ it was honoured. `select_account` is therefore refused too.
50
+
51
+ | Value | Nobody signed in | Signed in |
52
+ |---|---|---|
53
+ | (none) | The endpoint's own `access_denied`, or whatever the login firewall does | The flow goes on |
54
+ | `none` | `login_required` at the validated redirect URI, with `state` echoed back | The flow goes on; the consent listener raises `consent_required(event)` if consent is still needed |
55
+ | `login` | Redirect to `login_path` | Redirect to `login_path` — they sign in again |
56
+ | `consent` | The login firewall's business; then the consent listener's | The consent listener shows its page |
57
+
58
+ ## `max_age`
59
+
60
+ A whole number of seconds, zero or more; anything else is `invalid_request`.
61
+
62
+ The sign-in's age is the newest authentication proof on the firewall's token, measured against the
63
+ injected clock. A token that proves nothing — a remembered login — has no known sign-in time, so it
64
+ is never fresh enough for any `max_age`.
65
+
66
+ | State | Outcome |
67
+ |---|---|
68
+ | Fresh enough | The flow goes on; the ID token states `auth_time` |
69
+ | Too old, or unknown | A re-login: redirect to `login_path`, the request parked |
70
+ | Too old with `prompt=none` | `login_required` — no page may be shown |
71
+
72
+ ## The re-login round trip
73
+
74
+ `prompt=login`, or an exceeded `max_age`, parks the authorization request in the login firewall's
75
+ session and answers a `302` to `login_path`:
76
+
77
+ 1. The request kept is the original one with `login` taken out of `prompt`, and `prompt` dropped
78
+ when nothing is left in it — so the return trip goes on to the code instead of asking again. A
79
+ request that arrived as a posted form is kept as the `GET` the return trip makes.
80
+ 2. The end-user signs in on the login page. The firewall sends them back to the parked path.
81
+ 3. The chain runs again. `prompt` no longer asks for a re-login, the sign-in is new, and the flow
82
+ goes on.
83
+
84
+ The firewall must keep a session for step 1. A stateless one raises `InvalidConfigurationError`
85
+ naming `stateless`, the firewall and `login_firewall`.
86
+
87
+ ## `/userinfo`
88
+
89
+ | Request | Answer |
90
+ |---|---|
91
+ | `GET` or form `POST` with a bearer token granted `openid`, naming an end-user | `200`, `application/json`, `cache-control: no-store`, `{"sub": …, **claims}` |
92
+ | No token, or a token this server did not issue | `401` `invalid_token`, with a `Bearer` challenge |
93
+ | A client-credentials token, whose caller names no end-user | `401` `invalid_token` |
94
+ | A token not granted `openid` | `403` `insufficient_scope` |
95
+ | A `POST` whose `Content-Type` is not `application/x-www-form-urlencoded`, or that names none — even with no body | `400` `invalid_request` |
96
+
97
+ `sub` is answered first and is the identifier the ID token named, so a client can compare them; no
98
+ claim of the identity provider's replaces it, not even one called `sub`. The token is read from the
99
+ `Authorization` header only — a token in the form body alone is not read.
@@ -0,0 +1,21 @@
1
+ """OpenID Connect for the xtr OAuth 2.0 authorization server.
2
+
3
+ :data:`AUTH_TIME_LISTENER_PRIORITY` is the priority the provider records the
4
+ sign-in time at, on the authorization server's consent event; an application's
5
+ consent listener runs below it.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from importlib.metadata import PackageNotFoundError, version
11
+
12
+ from .event_listener.auth_time_listener import AUTH_TIME_LISTENER_PRIORITY
13
+
14
+ __all__ = ["AUTH_TIME_LISTENER_PRIORITY", "__version__"]
15
+
16
+ try:
17
+ __version__ = version("xtr-security-openid-connect-server")
18
+ except PackageNotFoundError: # pragma: no cover
19
+ # Running from a source tree with no installed metadata to read; having no
20
+ # version is better than refusing to import.
21
+ __version__ = "0+unknown"
@@ -0,0 +1,32 @@
1
+ """Where each endpoint of the provider lives by default, and what its route is called.
2
+
3
+ The route loaders contribute each route at its default path under its name;
4
+ a routes file may move it, and whatever publishes its URL finds it by the
5
+ endpoint it calls, so it is published where it is served.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from types import MappingProxyType
11
+ from typing import TYPE_CHECKING, Final
12
+
13
+ if TYPE_CHECKING:
14
+ from collections.abc import Mapping
15
+
16
+ __all__ = ["CONFIGURATION_PATH", "ROUTE_NAMES", "USERINFO_PATH"]
17
+
18
+ #: Where a client reads the claims of the end-user a token was issued for.
19
+ USERINFO_PATH: Final = "/userinfo"
20
+
21
+ #: Where a client discovers the provider. Fixed under the issuer, so a routes
22
+ #: file leaves it at the root (OpenID Connect Discovery 1.0, Section 4).
23
+ CONFIGURATION_PATH: Final = "/.well-known/openid-configuration"
24
+
25
+ #: What each route is called, so an application can build a URL to one.
26
+ #: Read-only: the route loaders read it and do not own it.
27
+ ROUTE_NAMES: Final[Mapping[str, str]] = MappingProxyType(
28
+ {
29
+ USERINFO_PATH: "openid_connect_server_userinfo",
30
+ CONFIGURATION_PATH: "openid_connect_server_configuration",
31
+ },
32
+ )
@@ -0,0 +1,17 @@
1
+ """The bundle wiring the OpenID Connect provider into an application.
2
+
3
+ An application lists :class:`OpenIdConnectServerBundle`, which brings the
4
+ OAuth 2.0 authorization server's bundle along, and configures it with an
5
+ :class:`OpenIdConnectServerConfig`.
6
+
7
+ :data:`PROMPT_LISTENER` is the qualifier of the chain step deciding ``prompt``
8
+ and ``max_age``: the application lists it in the ``listeners`` of the firewall
9
+ that signs end-users in.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from .openid_connect_server_bundle import PROMPT_LISTENER, OpenIdConnectServerBundle
15
+ from .openid_connect_server_config import OpenIdConnectServerConfig
16
+
17
+ __all__ = ["PROMPT_LISTENER", "OpenIdConnectServerBundle", "OpenIdConnectServerConfig"]