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.
- xtr_security_openid_connect_server/.agents/skills/xtr-security-openid-connect-server/SKILL.md +332 -0
- xtr_security_openid_connect_server/.agents/skills/xtr-security-openid-connect-server/references/configuration.md +95 -0
- xtr_security_openid_connect_server/.agents/skills/xtr-security-openid-connect-server/references/flows.md +99 -0
- xtr_security_openid_connect_server/__init__.py +21 -0
- xtr_security_openid_connect_server/_endpoint_paths.py +32 -0
- xtr_security_openid_connect_server/bundle/__init__.py +17 -0
- xtr_security_openid_connect_server/bundle/_service_factories.py +188 -0
- xtr_security_openid_connect_server/bundle/openid_connect_server_bundle.py +230 -0
- xtr_security_openid_connect_server/bundle/openid_connect_server_config.py +98 -0
- xtr_security_openid_connect_server/claim_extractor.py +152 -0
- xtr_security_openid_connect_server/controller/__init__.py +11 -0
- xtr_security_openid_connect_server/controller/user_info_controller.py +171 -0
- xtr_security_openid_connect_server/entities/__init__.py +26 -0
- xtr_security_openid_connect_server/entities/claim_set_entity.py +62 -0
- xtr_security_openid_connect_server/entities/claim_set_entity_interface.py +28 -0
- xtr_security_openid_connect_server/entities/claim_set_interface.py +38 -0
- xtr_security_openid_connect_server/entities/user_claims_entity_interface.py +28 -0
- xtr_security_openid_connect_server/event_listener/__init__.py +12 -0
- xtr_security_openid_connect_server/event_listener/auth_time_listener.py +52 -0
- xtr_security_openid_connect_server/exception/__init__.py +29 -0
- xtr_security_openid_connect_server/exception/invalid_argument_error.py +24 -0
- xtr_security_openid_connect_server/exception/invalid_configuration_error.py +28 -0
- xtr_security_openid_connect_server/exception/open_id_connect_server_error.py +21 -0
- xtr_security_openid_connect_server/exception/unsupported_algorithm_error.py +47 -0
- xtr_security_openid_connect_server/firewall/__init__.py +12 -0
- xtr_security_openid_connect_server/firewall/open_id_prompt_listener.py +287 -0
- xtr_security_openid_connect_server/grant/__init__.py +13 -0
- xtr_security_openid_connect_server/grant/open_id_auth_code_grant.py +242 -0
- xtr_security_openid_connect_server/id_token_settings.py +63 -0
- xtr_security_openid_connect_server/open_id_metadata_contributor.py +112 -0
- xtr_security_openid_connect_server/prompt.py +126 -0
- xtr_security_openid_connect_server/py.typed +0 -0
- xtr_security_openid_connect_server/recipe/__init__.py +14 -0
- xtr_security_openid_connect_server/recipe/files/config/openid_connect_server.py.tmpl +46 -0
- xtr_security_openid_connect_server/recipe/files/config/routes/openid_connect_server.py.tmpl +37 -0
- xtr_security_openid_connect_server/recipe/manifest.toml +36 -0
- xtr_security_openid_connect_server/repositories/__init__.py +13 -0
- xtr_security_openid_connect_server/repositories/identity_provider_interface.py +55 -0
- xtr_security_openid_connect_server/request_types/__init__.py +15 -0
- xtr_security_openid_connect_server/request_types/open_id_authorization_request.py +88 -0
- xtr_security_openid_connect_server/response_types/__init__.py +12 -0
- xtr_security_openid_connect_server/response_types/id_token_response.py +161 -0
- xtr_security_openid_connect_server/routing/__init__.py +28 -0
- xtr_security_openid_connect_server/routing/_endpoints.py +103 -0
- xtr_security_openid_connect_server/token_hash.py +84 -0
- xtr_security_openid_connect_server-6.3.0.dist-info/METADATA +530 -0
- xtr_security_openid_connect_server-6.3.0.dist-info/RECORD +50 -0
- xtr_security_openid_connect_server-6.3.0.dist-info/WHEEL +4 -0
- xtr_security_openid_connect_server-6.3.0.dist-info/entry_points.txt +6 -0
- 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"]
|