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.
- xtr_security_openid_connect_server-6.3.0/LICENSE +22 -0
- xtr_security_openid_connect_server-6.3.0/PKG-INFO +530 -0
- xtr_security_openid_connect_server-6.3.0/README.md +496 -0
- xtr_security_openid_connect_server-6.3.0/pyproject.toml +185 -0
- xtr_security_openid_connect_server-6.3.0/pyproject.toml.orig +186 -0
- 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
- 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
- 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
- xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/__init__.py +21 -0
- xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/_endpoint_paths.py +32 -0
- xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/bundle/__init__.py +17 -0
- xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/bundle/_service_factories.py +188 -0
- xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/bundle/openid_connect_server_bundle.py +230 -0
- xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/bundle/openid_connect_server_config.py +98 -0
- xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/claim_extractor.py +152 -0
- xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/controller/__init__.py +11 -0
- xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/controller/user_info_controller.py +171 -0
- xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/entities/__init__.py +26 -0
- xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/entities/claim_set_entity.py +62 -0
- xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/entities/claim_set_entity_interface.py +28 -0
- xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/entities/claim_set_interface.py +38 -0
- xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/entities/user_claims_entity_interface.py +28 -0
- xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/event_listener/__init__.py +12 -0
- xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/event_listener/auth_time_listener.py +52 -0
- xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/exception/__init__.py +29 -0
- xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/exception/invalid_argument_error.py +24 -0
- xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/exception/invalid_configuration_error.py +28 -0
- xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/exception/open_id_connect_server_error.py +21 -0
- xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/exception/unsupported_algorithm_error.py +47 -0
- xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/firewall/__init__.py +12 -0
- xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/firewall/open_id_prompt_listener.py +287 -0
- xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/grant/__init__.py +13 -0
- xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/grant/open_id_auth_code_grant.py +242 -0
- xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/id_token_settings.py +63 -0
- xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/open_id_metadata_contributor.py +112 -0
- xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/prompt.py +126 -0
- xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/py.typed +0 -0
- xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/recipe/__init__.py +14 -0
- xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/recipe/files/config/openid_connect_server.py.tmpl +46 -0
- 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
- xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/recipe/manifest.toml +36 -0
- xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/repositories/__init__.py +13 -0
- xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/repositories/identity_provider_interface.py +55 -0
- xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/request_types/__init__.py +15 -0
- xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/request_types/open_id_authorization_request.py +88 -0
- xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/response_types/__init__.py +12 -0
- xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/response_types/id_token_response.py +161 -0
- xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/routing/__init__.py +28 -0
- xtr_security_openid_connect_server-6.3.0/src/xtr_security_openid_connect_server/routing/_endpoints.py +103 -0
- 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).
|