xtr-security-oauth2-server 4.0.2__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 (178) hide show
  1. xtr_security_oauth2_server/.agents/skills/xtr-security-oauth2-server/SKILL.md +373 -0
  2. xtr_security_oauth2_server/.agents/skills/xtr-security-oauth2-server/references/configuration.md +104 -0
  3. xtr_security_oauth2_server/.agents/skills/xtr-security-oauth2-server/references/events.md +105 -0
  4. xtr_security_oauth2_server/.agents/skills/xtr-security-oauth2-server/references/extending.md +199 -0
  5. xtr_security_oauth2_server/.agents/skills/xtr-security-oauth2-server/references/persistence.md +122 -0
  6. xtr_security_oauth2_server/__init__.py +14 -0
  7. xtr_security_oauth2_server/_endpoint_paths.py +82 -0
  8. xtr_security_oauth2_server/_service_qualifier.py +18 -0
  9. xtr_security_oauth2_server/authorization_server_metadata.py +174 -0
  10. xtr_security_oauth2_server/bundle/__init__.py +43 -0
  11. xtr_security_oauth2_server/bundle/_application_stores.py +82 -0
  12. xtr_security_oauth2_server/bundle/_command_wiring.py +74 -0
  13. xtr_security_oauth2_server/bundle/_endpoint_wiring.py +79 -0
  14. xtr_security_oauth2_server/bundle/_grant_factories.py +210 -0
  15. xtr_security_oauth2_server/bundle/_metadata_factories.py +163 -0
  16. xtr_security_oauth2_server/bundle/_no_persistence.py +68 -0
  17. xtr_security_oauth2_server/bundle/_parameters.py +42 -0
  18. xtr_security_oauth2_server/bundle/_persistence_factories.py +168 -0
  19. xtr_security_oauth2_server/bundle/_persistence_wiring.py +272 -0
  20. xtr_security_oauth2_server/bundle/_required_settings.py +87 -0
  21. xtr_security_oauth2_server/bundle/_server_factories.py +226 -0
  22. xtr_security_oauth2_server/bundle/_service_factories.py +219 -0
  23. xtr_security_oauth2_server/bundle/_service_wiring.py +230 -0
  24. xtr_security_oauth2_server/bundle/_signing_factories.py +151 -0
  25. xtr_security_oauth2_server/bundle/authorization_server_config.py +205 -0
  26. xtr_security_oauth2_server/bundle/client_config.py +63 -0
  27. xtr_security_oauth2_server/bundle/encryption_key_pass.py +79 -0
  28. xtr_security_oauth2_server/bundle/grant_type_pass.py +149 -0
  29. xtr_security_oauth2_server/bundle/metadata_pass.py +104 -0
  30. xtr_security_oauth2_server/bundle/oauth2_authenticator_config.py +27 -0
  31. xtr_security_oauth2_server/bundle/oauth2_server_bundle.py +191 -0
  32. xtr_security_oauth2_server/bundle/oauth2_server_config.py +85 -0
  33. xtr_security_oauth2_server/bundle/persistence_configs.py +99 -0
  34. xtr_security_oauth2_server/bundle/resource_server_config.py +50 -0
  35. xtr_security_oauth2_server/bundle/scopes_config.py +68 -0
  36. xtr_security_oauth2_server/command/__init__.py +19 -0
  37. xtr_security_oauth2_server/command/_key_files.py +85 -0
  38. xtr_security_oauth2_server/command/_key_paths.py +73 -0
  39. xtr_security_oauth2_server/command/_registry.py +16 -0
  40. xtr_security_oauth2_server/command/clear_expired_tokens_command.py +122 -0
  41. xtr_security_oauth2_server/command/create_client_command.py +220 -0
  42. xtr_security_oauth2_server/command/delete_client_command.py +49 -0
  43. xtr_security_oauth2_server/command/generate_key_pair_command.py +230 -0
  44. xtr_security_oauth2_server/command/list_clients_command.py +133 -0
  45. xtr_security_oauth2_server/command/update_client_command.py +178 -0
  46. xtr_security_oauth2_server/controller/__init__.py +28 -0
  47. xtr_security_oauth2_server/controller/_served_response.py +53 -0
  48. xtr_security_oauth2_server/controller/authorization_controller.py +264 -0
  49. xtr_security_oauth2_server/controller/device_code_controller.py +67 -0
  50. xtr_security_oauth2_server/controller/introspection_controller.py +70 -0
  51. xtr_security_oauth2_server/controller/jwks_controller.py +66 -0
  52. xtr_security_oauth2_server/controller/metadata_controller.py +111 -0
  53. xtr_security_oauth2_server/controller/revocation_controller.py +71 -0
  54. xtr_security_oauth2_server/controller/token_controller.py +74 -0
  55. xtr_security_oauth2_server/converter/__init__.py +22 -0
  56. xtr_security_oauth2_server/converter/scope_converter.py +55 -0
  57. xtr_security_oauth2_server/converter/scope_converter_interface.py +47 -0
  58. xtr_security_oauth2_server/converter/user_converter.py +43 -0
  59. xtr_security_oauth2_server/converter/user_converter_interface.py +30 -0
  60. xtr_security_oauth2_server/entity/__init__.py +33 -0
  61. xtr_security_oauth2_server/entity/access_token.py +35 -0
  62. xtr_security_oauth2_server/entity/auth_code.py +27 -0
  63. xtr_security_oauth2_server/entity/client.py +76 -0
  64. xtr_security_oauth2_server/entity/device_code.py +27 -0
  65. xtr_security_oauth2_server/entity/refresh_token.py +26 -0
  66. xtr_security_oauth2_server/entity/scope.py +24 -0
  67. xtr_security_oauth2_server/entity/user.py +23 -0
  68. xtr_security_oauth2_server/event/__init__.py +28 -0
  69. xtr_security_oauth2_server/event/access_token_extra_claims_resolve_event.py +86 -0
  70. xtr_security_oauth2_server/event/authorization_request_resolve_event.py +153 -0
  71. xtr_security_oauth2_server/event/authorization_request_resolve_event_factory.py +89 -0
  72. xtr_security_oauth2_server/event/pre_save_client_event.py +47 -0
  73. xtr_security_oauth2_server/event/scope_resolve_event.py +80 -0
  74. xtr_security_oauth2_server/event/token_request_resolve_event.py +51 -0
  75. xtr_security_oauth2_server/event/user_resolve_event.py +82 -0
  76. xtr_security_oauth2_server/event_listener/__init__.py +16 -0
  77. xtr_security_oauth2_server/event_listener/add_client_default_scopes_listener.py +60 -0
  78. xtr_security_oauth2_server/exception/__init__.py +21 -0
  79. xtr_security_oauth2_server/exception/client_already_registered_error.py +29 -0
  80. xtr_security_oauth2_server/exception/invalid_configuration_error.py +32 -0
  81. xtr_security_oauth2_server/factory/__init__.py +12 -0
  82. xtr_security_oauth2_server/factory/oauth2_factory.py +130 -0
  83. xtr_security_oauth2_server/manager/__init__.py +41 -0
  84. xtr_security_oauth2_server/manager/access_token_manager_interface.py +36 -0
  85. xtr_security_oauth2_server/manager/authorization_code_manager_interface.py +35 -0
  86. xtr_security_oauth2_server/manager/client_filter.py +79 -0
  87. xtr_security_oauth2_server/manager/client_manager_interface.py +51 -0
  88. xtr_security_oauth2_server/manager/device_code_manager_interface.py +38 -0
  89. xtr_security_oauth2_server/manager/in_memory/__init__.py +29 -0
  90. xtr_security_oauth2_server/manager/in_memory/access_token_manager.py +82 -0
  91. xtr_security_oauth2_server/manager/in_memory/authorization_code_manager.py +68 -0
  92. xtr_security_oauth2_server/manager/in_memory/client_manager.py +95 -0
  93. xtr_security_oauth2_server/manager/in_memory/device_code_manager.py +73 -0
  94. xtr_security_oauth2_server/manager/in_memory/refresh_token_manager.py +80 -0
  95. xtr_security_oauth2_server/manager/in_memory/scope_manager.py +45 -0
  96. xtr_security_oauth2_server/manager/null/__init__.py +12 -0
  97. xtr_security_oauth2_server/manager/null/access_token_manager.py +56 -0
  98. xtr_security_oauth2_server/manager/orm/__init__.py +31 -0
  99. xtr_security_oauth2_server/manager/orm/_changed_rows.py +20 -0
  100. xtr_security_oauth2_server/manager/orm/access_token_manager.py +133 -0
  101. xtr_security_oauth2_server/manager/orm/authorization_code_manager.py +83 -0
  102. xtr_security_oauth2_server/manager/orm/client_manager.py +195 -0
  103. xtr_security_oauth2_server/manager/orm/device_code_manager.py +91 -0
  104. xtr_security_oauth2_server/manager/orm/refresh_token_manager.py +114 -0
  105. xtr_security_oauth2_server/manager/refresh_token_manager_interface.py +48 -0
  106. xtr_security_oauth2_server/manager/scope_manager_interface.py +28 -0
  107. xtr_security_oauth2_server/metadata_contributor_interface.py +44 -0
  108. xtr_security_oauth2_server/model/__init__.py +49 -0
  109. xtr_security_oauth2_server/model/_short_identifier.py +19 -0
  110. xtr_security_oauth2_server/model/abstract_client.py +152 -0
  111. xtr_security_oauth2_server/model/access_token.py +92 -0
  112. xtr_security_oauth2_server/model/access_token_interface.py +65 -0
  113. xtr_security_oauth2_server/model/authorization_code.py +91 -0
  114. xtr_security_oauth2_server/model/authorization_code_interface.py +65 -0
  115. xtr_security_oauth2_server/model/client.py +18 -0
  116. xtr_security_oauth2_server/model/client_interface.py +103 -0
  117. xtr_security_oauth2_server/model/device_code.py +167 -0
  118. xtr_security_oauth2_server/model/device_code_interface.py +100 -0
  119. xtr_security_oauth2_server/model/refresh_token.py +79 -0
  120. xtr_security_oauth2_server/model/refresh_token_interface.py +55 -0
  121. xtr_security_oauth2_server/null_metadata_contributor.py +45 -0
  122. xtr_security_oauth2_server/oauth2_events.py +69 -0
  123. xtr_security_oauth2_server/persistence/__init__.py +15 -0
  124. xtr_security_oauth2_server/persistence/mapping/__init__.py +20 -0
  125. xtr_security_oauth2_server/persistence/mapping/_fields.py +85 -0
  126. xtr_security_oauth2_server/persistence/mapping/_tables.py +259 -0
  127. xtr_security_oauth2_server/persistence/mapping/client_mapping_mixin.py +72 -0
  128. xtr_security_oauth2_server/persistence/mapping/driver.py +280 -0
  129. xtr_security_oauth2_server/persistence/mapping/mapped_table.py +28 -0
  130. xtr_security_oauth2_server/persistence/type/__init__.py +26 -0
  131. xtr_security_oauth2_server/persistence/type/grant.py +30 -0
  132. xtr_security_oauth2_server/persistence/type/imploded_array.py +81 -0
  133. xtr_security_oauth2_server/persistence/type/redirect_uri.py +34 -0
  134. xtr_security_oauth2_server/persistence/type/scope.py +34 -0
  135. xtr_security_oauth2_server/persistence/type/utc_datetime.py +65 -0
  136. xtr_security_oauth2_server/py.typed +0 -0
  137. xtr_security_oauth2_server/recipe/__init__.py +19 -0
  138. xtr_security_oauth2_server/recipe/files/config/oauth2_server.py.tmpl +63 -0
  139. xtr_security_oauth2_server/recipe/files/config/routes/oauth2_server.py.tmpl +27 -0
  140. xtr_security_oauth2_server/recipe/manifest.toml +53 -0
  141. xtr_security_oauth2_server/repository/__init__.py +39 -0
  142. xtr_security_oauth2_server/repository/_stored_client.py +47 -0
  143. xtr_security_oauth2_server/repository/access_token_repository.py +165 -0
  144. xtr_security_oauth2_server/repository/auth_code_repository.py +116 -0
  145. xtr_security_oauth2_server/repository/client_repository.py +150 -0
  146. xtr_security_oauth2_server/repository/device_code_repository.py +207 -0
  147. xtr_security_oauth2_server/repository/null_access_token_repository.py +90 -0
  148. xtr_security_oauth2_server/repository/refresh_token_repository.py +114 -0
  149. xtr_security_oauth2_server/repository/scope_repository.py +148 -0
  150. xtr_security_oauth2_server/repository/user_repository.py +92 -0
  151. xtr_security_oauth2_server/routing/__init__.py +14 -0
  152. xtr_security_oauth2_server/routing/_endpoints.py +118 -0
  153. xtr_security_oauth2_server/routing/oauth2_server_route_loader.py +52 -0
  154. xtr_security_oauth2_server/routing/well_known_route_loader.py +32 -0
  155. xtr_security_oauth2_server/security/__init__.py +22 -0
  156. xtr_security_oauth2_server/security/authentication/__init__.py +7 -0
  157. xtr_security_oauth2_server/security/authentication/token/__init__.py +7 -0
  158. xtr_security_oauth2_server/security/authentication/token/oauth2_token.py +117 -0
  159. xtr_security_oauth2_server/security/authenticator/__init__.py +7 -0
  160. xtr_security_oauth2_server/security/authenticator/oauth2_authenticator.py +277 -0
  161. xtr_security_oauth2_server/security/exception/__init__.py +16 -0
  162. xtr_security_oauth2_server/security/exception/oauth2_authentication_error.py +93 -0
  163. xtr_security_oauth2_server/security/exception/oauth2_authentication_failed_error.py +44 -0
  164. xtr_security_oauth2_server/security/user/__init__.py +7 -0
  165. xtr_security_oauth2_server/security/user/client_credentials_user.py +53 -0
  166. xtr_security_oauth2_server/service/__init__.py +16 -0
  167. xtr_security_oauth2_server/service/credentials_revoker/__init__.py +12 -0
  168. xtr_security_oauth2_server/service/credentials_revoker/orm_credentials_revoker.py +152 -0
  169. xtr_security_oauth2_server/service/credentials_revoker_interface.py +41 -0
  170. xtr_security_oauth2_server/value_object/__init__.py +21 -0
  171. xtr_security_oauth2_server/value_object/grant.py +45 -0
  172. xtr_security_oauth2_server/value_object/redirect_uri.py +54 -0
  173. xtr_security_oauth2_server/value_object/scope.py +46 -0
  174. xtr_security_oauth2_server-4.0.2.dist-info/METADATA +667 -0
  175. xtr_security_oauth2_server-4.0.2.dist-info/RECORD +178 -0
  176. xtr_security_oauth2_server-4.0.2.dist-info/WHEEL +4 -0
  177. xtr_security_oauth2_server-4.0.2.dist-info/entry_points.txt +6 -0
  178. xtr_security_oauth2_server-4.0.2.dist-info/licenses/LICENSE +24 -0
@@ -0,0 +1,373 @@
1
+ ---
2
+ name: xtr-security-oauth2-server
3
+ description: How to host an OAuth 2.0 authorization server with xtr-security-oauth2-server and its protocol library xtr-security-oauth2-server-core, and accept its access tokens on an API. Use when an application must issue tokens to third-party clients or services, run the client-credentials, authorization-code with PKCE, refresh-token, device, password or implicit flow, register or administer OAuth clients, show a consent page or approve an authorization request, protect an API with OAuth2AuthenticatorConfig or require a scope, publish the authorization-server metadata or JWKS, revoke or introspect a token, add a claim or a field to the token response, keep clients and tokens in memory, in a database with xtr-orm or in its own stores, own its client table, build an AuthorizationServer or ResourceServer by hand, write a custom grant or an add-on bundle on top of OAuth2ServerBundle, run the oauth2-server commands, or fix the InvalidConfigurationError naming a missing issuer, key, scope or persistence.
4
+ ---
5
+
6
+ # xtr-security-oauth2-server
7
+
8
+ An authorization server an application hosts: it registers clients, runs the OAuth 2.0 grants
9
+ against them, issues signed access tokens (RFC 9068) and sealed refresh tokens, and a firewall
10
+ authenticator accepts those access tokens back. Two packages: `xtr-security-oauth2-server-core`
11
+ is the protocol library (servers, grants, entities, repositories, response types, the resource
12
+ server), buildable by hand; `xtr-security-oauth2-server` is its bundle, with stores, endpoints,
13
+ commands and the `oauth2` firewall key. This skill covers both.
14
+
15
+ It is an add-on of the security family: firewalls, voters and user providers are the
16
+ `xtr-security` skill's; the end user's own sign-in token is the `xtr-security-jwt` skill's.
17
+
18
+ ## Quick reference
19
+
20
+ - List `OAuth2ServerBundle` in `<app>/bundles.py`; write `<app>/config/oauth2_server.py` with an
21
+ `OAuth2ServerConfig` naming `issuer`, `private_key`, `encryption_key`, `scopes.available` and
22
+ `persistence`. Each missing one is reported by the first service needing it.
23
+ - `setup(app, kernel)` adds every endpoint (resources `oauth2_server` and the fixed
24
+ `oauth2_server_well_known`); move them or leave some out in `<app>/config/routes/oauth2_server.py`.
25
+ Put an unbound `Firewall()` on the application — the endpoints carry no dependency of their own.
26
+ - Three firewalls: one claiming `^/authorize` that signs the end user in, none on `/token`,
27
+ `/revoke`, `/introspect`, `/device-code` and `/.well-known/*`, and `OAuth2AuthenticatorConfig()`
28
+ on the API's.
29
+ - A listener on `OAuth2Events.AUTHORIZATION_REQUEST_RESOLVE` approves, denies or shows a consent
30
+ page; a request is denied until one approves it.
31
+ - A scope becomes the role `ROLE_OAUTH2_<SCOPE>`; require one with `Firewall("api").scoped("x")`.
32
+ - `oauth2-server:generate-keypair` mints the signing pair; the encryption key is 32 random bytes,
33
+ base64url, and no command mints it.
34
+ - Inject `AuthorizationServer`, `TokenServer`, `ResourceServer`, `ClientManagerInterface` and the
35
+ other managers; this server's own key loader and hasher are under `Target("oauth2_server")`.
36
+ - Every error derives from `OAuth2Error` (`xtr_security_oauth2_server_core.exception`).
37
+
38
+ ## Configure the server
39
+
40
+ ```python
41
+ # app/config/oauth2_server.py
42
+ from xtr_dependency_injection import configure, env
43
+ from xtr_security_oauth2_server.bundle import (
44
+ AuthorizationServerConfig,
45
+ InMemoryPersistenceConfig,
46
+ OAuth2ServerConfig,
47
+ ScopesConfig,
48
+ )
49
+
50
+
51
+ @configure
52
+ def oauth2_server() -> OAuth2ServerConfig:
53
+ return OAuth2ServerConfig(
54
+ authorization_server=AuthorizationServerConfig(
55
+ issuer="https://authorization.example.com",
56
+ private_key=env("resolve:OAUTH2_SERVER_PRIVATE_KEY_PATH"),
57
+ encryption_key=env("OAUTH2_SERVER_ENCRYPTION_KEY"),
58
+ encryption_key_type="key",
59
+ enable_device_code_grant=False,
60
+ ),
61
+ scopes=ScopesConfig(
62
+ available=("invoices:read", "invoices:write"), default=("invoices:read",)
63
+ ),
64
+ persistence=InMemoryPersistenceConfig(),
65
+ )
66
+ ```
67
+
68
+ - `issuer` is an origin only: the description lives at `/.well-known/oauth-authorization-server`
69
+ under it, which is why `oauth2_server_well_known` cannot take a prefix.
70
+ - The device flow is on by default and needs `device_code_verification_uri` — name the page, or
71
+ turn the flow off as above.
72
+ - `InMemoryPersistenceConfig()` forgets everything at exit; `OrmPersistenceConfig()` keeps it in
73
+ an xtr-orm connection, `CustomPersistenceConfig(...)` in six services of the application's.
74
+
75
+ Every field and default: [references/configuration.md](references/configuration.md). Stores, the
76
+ tables and an application-owned client class: [references/persistence.md](references/persistence.md).
77
+
78
+ ## Write the firewalls and serve the endpoints
79
+
80
+ ```python
81
+ # app/config/security.py
82
+ from xtr_dependency_injection import configure, env
83
+ from xtr_security.bundle import AccessControlConfig, FirewallConfig, SecurityConfig
84
+ from xtr_security_jwt.bundle import (
85
+ AuthorizationHeaderExtractorConfig,
86
+ CookieExtractorConfig,
87
+ JwtAuthenticatorConfig,
88
+ JwtConfig,
89
+ JwtUserProviderConfig,
90
+ TokenExtractorsConfig,
91
+ )
92
+ from xtr_security_oauth2_server.bundle import OAuth2AuthenticatorConfig
93
+
94
+
95
+ @configure
96
+ def jwt() -> JwtConfig:
97
+ return JwtConfig(
98
+ secret_key=env("resolve:JWT_SECRET_KEY_PATH"),
99
+ token_extractors=TokenExtractorsConfig(
100
+ authorization_header=AuthorizationHeaderExtractorConfig(enabled=False),
101
+ cookie=CookieExtractorConfig(enabled=True, name="session"),
102
+ ),
103
+ )
104
+
105
+
106
+ @configure
107
+ def security() -> SecurityConfig:
108
+ return SecurityConfig(
109
+ providers={"people": JwtUserProviderConfig()},
110
+ firewalls={
111
+ "login": FirewallConfig(
112
+ pattern=r"^/authorize",
113
+ provider="people",
114
+ authenticators=(JwtAuthenticatorConfig(),),
115
+ ),
116
+ "api": FirewallConfig(
117
+ pattern=r"^/api",
118
+ provider="people",
119
+ authenticators=(OAuth2AuthenticatorConfig(),),
120
+ ),
121
+ },
122
+ access_control=(
123
+ AccessControlConfig(path=r"^/(authorize|api)", attribute="IS_AUTHENTICATED"),
124
+ ),
125
+ )
126
+ ```
127
+
128
+ ```python
129
+ # main.py — outside the app package, so the kernel's scan does not import it
130
+ from typing import Annotated
131
+
132
+ from fastapi import FastAPI
133
+ from xtr_dependency_injection import Kernel
134
+ from xtr_http_kernel import setup
135
+ from xtr_security_core.user.user_interface import UserInterface
136
+ from xtr_security_http import CurrentUser, Firewall
137
+
138
+ app = FastAPI(dependencies=[Firewall()])
139
+
140
+
141
+ @app.get("/api/invoices", dependencies=[Firewall("api").scoped("invoices:read")])
142
+ async def invoices(user: Annotated[UserInterface, CurrentUser()]) -> dict[str, str]:
143
+ return {"caller": user.get_user_identifier()}
144
+
145
+
146
+ kernel = Kernel("app", concurrent_scoped_access=True)
147
+ setup(app, kernel)
148
+ ```
149
+
150
+ - The `login` firewall needs `JwtBundle` listed too; any authenticator that establishes a person
151
+ will do. With none, `/authorize` answers `access_denied` and logs why on `oauth2_server`.
152
+ - A token from the client-credentials flow names no person: the caller is a
153
+ `ClientCredentialsUser` carrying the client's identifier and no roles of its own.
154
+ - A missing scope is `403 insufficient_scope`; no token is `401` with a bearer challenge.
155
+
156
+ ## Answer the consent question
157
+
158
+ ```python
159
+ from xtr_event_dispatcher import as_event_listener
160
+ from xtr_http_kernel.bridge import Response
161
+ from xtr_security_oauth2_server.event.authorization_request_resolve_event import (
162
+ AuthorizationRequestResolveEvent,
163
+ )
164
+ from xtr_security_oauth2_server.oauth2_events import OAuth2Events
165
+
166
+
167
+ @as_event_listener(OAuth2Events.AUTHORIZATION_REQUEST_RESOLVE)
168
+ async def ask_for_consent(event: AuthorizationRequestResolveEvent) -> None:
169
+ if event.get_client().get_identifier() == "dashboard": # a first-party client
170
+ _ = event.resolve_authorization(AuthorizationRequestResolveEvent.APPROVED)
171
+ return
172
+ page = Response(200).with_header("content-type", "text/html")
173
+ _ = await page.get_body().write(b"<form method='post'>Allow this client?</form>")
174
+ _ = event.set_response(page)
175
+ ```
176
+
177
+ Consent is stateless: the page posts the original query back to `/authorize`, and a listener
178
+ approves the second pass. Bind that form to the person it was shown to (a CSRF token you check
179
+ on the post) — nothing in this package does. `event.get_user()`, `get_scopes()`, `get_state()`
180
+ and `get_redirect_uri()` describe what is asked. More events, with a listener for each:
181
+ [references/events.md](references/events.md).
182
+
183
+ ## Register and administer clients
184
+
185
+ ```python
186
+ from typing import Annotated
187
+
188
+ import anyio.to_thread
189
+ from xtr_dependency_injection import Injected, Target, on_boot
190
+ from xtr_password_hasher import PasswordHasherInterface
191
+ from xtr_security_oauth2_server.manager.client_manager_interface import ClientManagerInterface
192
+ from xtr_security_oauth2_server.model.client import Client
193
+ from xtr_security_oauth2_server.value_object.grant import Grant
194
+ from xtr_security_oauth2_server.value_object.redirect_uri import RedirectUri
195
+ from xtr_security_oauth2_server.value_object.scope import Scope
196
+
197
+
198
+ @on_boot
199
+ async def register_demo_clients(
200
+ clients: Injected[ClientManagerInterface],
201
+ hasher: Annotated[PasswordHasherInterface, Target("oauth2_server")],
202
+ ) -> None:
203
+ billing = Client("Billing", "billing", await anyio.to_thread.run_sync(hasher.hash, "s3cret"))
204
+ _ = billing.set_grants(Grant("client_credentials")).set_scopes(Scope("invoices:read"))
205
+ dashboard = Client("Dashboard", "dashboard", None) # public: it holds no secret
206
+ _ = dashboard.set_grants(Grant("authorization_code"), Grant("refresh_token"))
207
+ _ = dashboard.set_redirect_uris(RedirectUri("https://dashboard.example.com/callback"))
208
+ for client in (billing, dashboard):
209
+ _ = client.set_active(True)
210
+ await clients.save(client)
211
+ ```
212
+
213
+ - Store the *hash* of a secret, made with the hasher under `Target("oauth2_server")`; a client
214
+ registered with secret `None` is public and must send a proof key.
215
+ - A client with no grants may use any; with no scopes it is given `scopes.default` when saved.
216
+ - `set_active(False)` and a save (or `update-client --deactivate`) stops a client: it is refused
217
+ as `invalid_client` wherever it names itself, holding a secret or not.
218
+ - With a database store use the commands instead: `oauth2-server:create-client NAME
219
+ [IDENTIFIER] [SECRET] --grant-type … --scope … --redirect-uri … [--public]` prints the secret
220
+ once; `update-client`, `delete-client`, `list-clients` administer; `clear-expired-tokens` is the
221
+ one to schedule.
222
+
223
+ ## Serve the device verification page
224
+
225
+ ```python
226
+ from fastapi import FastAPI
227
+ from xtr_dependency_injection import Injected
228
+ from xtr_security import Security
229
+ from xtr_security_oauth2_server.manager.device_code_manager_interface import (
230
+ DeviceCodeManagerInterface,
231
+ )
232
+ from xtr_security_oauth2_server_core.authorization_server import AuthorizationServer
233
+
234
+ app = FastAPI()
235
+
236
+
237
+ @app.post("/device") # behind the sign-in firewall, and rate limited
238
+ async def approve_device(
239
+ user_code: str,
240
+ approved: bool,
241
+ device_codes: Injected[DeviceCodeManagerInterface],
242
+ server: Injected[AuthorizationServer],
243
+ security: Injected[Security],
244
+ ) -> dict[str, str]:
245
+ found = await device_codes.find_by_user_code(user_code)
246
+ user = security.get_user()
247
+ if found is None or user is None:
248
+ return {"status": "unknown code"}
249
+ await server.complete_device_authorization_request(
250
+ found.get_identifier(), user.get_user_identifier(), approved
251
+ )
252
+ return {"status": "recorded"}
253
+ ```
254
+
255
+ Name the page as `device_code_verification_uri`. A user code is 8 characters of 20, so limit how
256
+ often the page may be guessed at (xtr-http-kernel's `RateLimited`).
257
+
258
+ ## Build the servers by hand
259
+
260
+ The core needs no container: `AuthorizationServer(client_repository, access_token_repository,
261
+ scope_repository, signer, encryption_key, encryption_key_type, dispatcher, response_type=None)`,
262
+ then `enable_grant_type(ClientCredentialsGrant(dispatcher, clock), ttl)`, and
263
+ `await server.respond_to_access_token_request(request, response)` with
264
+ `xtr-http-message-contracts` messages. `ResourceServer(access_tokens,
265
+ BearerTokenValidator(access_tokens, jws_provider, issuer, clock))` reads a token back. The
266
+ runnable whole is the core README's Quick start; grants, extra claims, a response type of your
267
+ own and an add-on bundle: [references/extending.md](references/extending.md).
268
+
269
+ ## Testing
270
+
271
+ Serve the application over ASGI inside its own lifespan; nothing is mocked. The kernel reads the
272
+ keys from the environment, so a test mints them:
273
+
274
+ ```python
275
+ import base64
276
+ import secrets
277
+
278
+ import httpx
279
+ import pytest
280
+ from xtr_security_jwt.services.key_loader import generate_signing_key
281
+
282
+ pytestmark = pytest.mark.anyio
283
+
284
+
285
+ @pytest.fixture
286
+ def anyio_backend() -> str:
287
+ return "asyncio"
288
+
289
+
290
+ async def test_a_service_is_issued_a_token(tmp_path, monkeypatch) -> None:
291
+ key = tmp_path / "oauth2.pem"
292
+ _ = key.write_text(generate_signing_key("RS256").as_pem(private=True).decode())
293
+ monkeypatch.setenv("OAUTH2_SERVER_PRIVATE_KEY_PATH", str(key))
294
+ monkeypatch.setenv("JWT_SECRET_KEY_PATH", str(key)) # the sign-in firewall's key
295
+ monkeypatch.setenv(
296
+ "OAUTH2_SERVER_ENCRYPTION_KEY",
297
+ base64.urlsafe_b64encode(secrets.token_bytes(32)).decode().rstrip("="),
298
+ )
299
+ from main import app # builds the kernel and calls setup(app, kernel)
300
+
301
+ async with app.router.lifespan_context(app):
302
+ transport = httpx.ASGITransport(app=app)
303
+ async with httpx.AsyncClient(transport=transport, base_url="https://test") as http:
304
+ issued = await http.post(
305
+ "/token",
306
+ data={"grant_type": "client_credentials", "scope": "invoices:read"},
307
+ auth=("billing", "s3cret"),
308
+ )
309
+ token = issued.json()["access_token"]
310
+ called = await http.get("/api/invoices", headers={"Authorization": f"Bearer {token}"})
311
+
312
+ assert called.json() == {"caller": "billing"}
313
+ ```
314
+
315
+ - `InMemoryPersistenceConfig()` is the test store: each kernel starts empty.
316
+ - Send cookies as a `Cookie` header, not per-request `cookies=`.
317
+ - Test a listener, a grant or a repository bridge with no kernel: they are plain objects.
318
+ - `await assert_zero_config(OAuth2ServerBundle)` from `xtr_dependency_injection.testing` holds:
319
+ the bundle boots unconfigured.
320
+
321
+ ## Use in an application
322
+
323
+ 1. **Install** — `uv add xtr-security-oauth2-server`; `[orm]` for a database store, `[console]`
324
+ for the commands.
325
+ 2. **Recipe** — `uv run xtr-recipes recipes:sync` lists `OAuth2ServerBundle`, writes
326
+ `<app>/config/oauth2_server.py`, `OAUTH2_SERVER_PRIVATE_KEY_PATH` and a commented-out
327
+ `OAUTH2_SERVER_ENCRYPTION_KEY` in `.env`, adds `/secrets/*.pem` to `.gitignore`, and writes
328
+ `<app>/config/routes/oauth2_server.py`. It prints the rest: the keys, the issuer, `setup`, the
329
+ three firewalls, the consent listener, a client, the scheduled sweep, the revision.
330
+ 3. **Activate** — `OAuth2ServerBundle: {"all": True}` in `BUNDLES` (and `JwtBundle` when the end
331
+ user signs in with a self-issued token); an unbound `Firewall()` on the application,
332
+ `Kernel(..., concurrent_scoped_access=True)` and `setup(app, kernel)`, which adds the
333
+ endpoints.
334
+ 4. **Brings along** — the security, clock, event-dispatcher and HTTP-kernel bundles always; the
335
+ logging and console bundles when installed.
336
+ 5. **Configure** — `issuer`, `private_key`, `encryption_key`, `scopes.available`, `persistence` in
337
+ `<app>/config/oauth2_server.py`; the three firewalls in `<app>/config/security.py`; a listener
338
+ on `OAuth2Events.AUTHORIZATION_REQUEST_RESOLVE`.
339
+ 6. **Environment** — `OAUTH2_SERVER_PRIVATE_KEY_PATH` (the signing key's file) and
340
+ `OAUTH2_SERVER_ENCRYPTION_KEY` (base64url of 32 random bytes). Read when a server is built,
341
+ never at build.
342
+ 7. **Ignore** — `/secrets/*.pem`.
343
+ 8. **Remove** — `uv run xtr-recipes recipes:remove xtr-security-oauth2-server`, then undo the
344
+ printed steps: router, firewalls, listener, clients.
345
+ 9. **Check** — `debug:bundles` shows `oauth2_server` active; `debug:config oauth2_server`;
346
+ `GET /.well-known/oauth-authorization-server`; `list oauth2-server` names six commands.
347
+
348
+ ## Errors
349
+
350
+ All derive from `OAuth2Error`.
351
+
352
+ | Error | Raised when |
353
+ | --- | --- |
354
+ | `OAuthServerError` | A protocol refusal; the endpoints answer it. By hand, `await error.generate_http_response(response)` |
355
+ | `InvalidConfigurationError` (`xtr_security_oauth2_server.exception`) | A setting unusable or missing where a service needs it — named, with `<app>/config/oauth2_server.py` |
356
+ | `ClientAlreadyRegisteredError` | Registering an identifier already taken |
357
+ | `OAuth2AuthenticationFailedError` (`xtr_security_oauth2_server.security.exception`) | The API's firewall refused a token: `401`, `invalid_token` |
358
+ | `InvalidArgumentError` (`xtr_security_oauth2_server_core.exception`) | A library object used wrongly — a reserved extra claim, an unreadable key |
359
+
360
+ ## Do not
361
+
362
+ - Do not name an issuer with a path, or try to prefix `oauth2_server_well_known` (refused):
363
+ RFC 8414 places the description under the issuer. Prefix `oauth2_server` instead.
364
+ - Do not `app.include_router(...)` these endpoints yourself: `setup` adds them.
365
+ - Do not put a firewall in front of `/token`, `/revoke`, `/introspect` or `/device-code`: the
366
+ server authenticates the client there.
367
+ - Do not approve every authorization request in a listener for third-party clients; show a page
368
+ and protect its form against cross-site posts.
369
+ - Do not store a client secret in plaintext, or compare one with `==`.
370
+ - Do not reuse the `JwtBundle` key: this server's key loader lives under `Target("oauth2_server")`.
371
+ - Do not mint keys at runtime or commit them; mount the deployment's own.
372
+ - Do not use `InMemoryPersistenceConfig()` in a deployment of more than one process.
373
+ - Do not turn `persist_access_token` off if access tokens must be revocable before they expire.
@@ -0,0 +1,104 @@
1
+ # OAuth2ServerConfig, field by field
2
+
3
+ `from xtr_security_oauth2_server.bundle import ...`. Frozen dataclasses, each buildable with no
4
+ arguments; a value of the wrong shape (an issuer with a path, a lifetime that is not positive, a
5
+ default scope that is not available, an empty role prefix) raises `InvalidConfigurationError` as
6
+ the configuration is built. A setting that is merely **missing** is reported later, by the first
7
+ service that needs it, naming the setting and `<app>/config/oauth2_server.py`:
8
+
9
+ | Missing | Reported when |
10
+ | --- | --- |
11
+ | `authorization_server.private_key` | anything signs: a token is issued, the key set is published |
12
+ | `authorization_server.encryption_key` | the authorization or token server is built |
13
+ | `authorization_server.issuer` | a token is signed or read, or the description is served |
14
+ | `scopes.available` | the scope manager or the description is built |
15
+ | `persistence` | any store is asked for |
16
+ | `authorization_server.device_code_verification_uri` | the device grant is built — on by default |
17
+
18
+ ## `OAuth2ServerConfig`
19
+
20
+ | Field | Default | What it is |
21
+ | --- | --- | --- |
22
+ | `authorization_server` | `AuthorizationServerConfig()` | What signs, seals, issues and runs |
23
+ | `resource_server` | `ResourceServerConfig()` | How tokens are verified |
24
+ | `scopes` | `ScopesConfig()` | The vocabulary of scopes |
25
+ | `persistence` | `None` | `InMemoryPersistenceConfig()`, `OrmPersistenceConfig(...)` or `CustomPersistenceConfig(...)` |
26
+ | `client` | `ClientConfig()` | `classname`: the client class, a subclass of `AbstractClient` (default `Client`) |
27
+ | `role_prefix` | `"ROLE_OAUTH2_"` | Scope `invoices:read` becomes role `ROLE_OAUTH2_INVOICES:READ`; never empty |
28
+
29
+ ## `AuthorizationServerConfig`
30
+
31
+ | Field | Default | What it is |
32
+ | --- | --- | --- |
33
+ | `issuer` | `None` | An origin — scheme and host, no path, query or fragment |
34
+ | `private_key` | `None` | PEM text or a file path; `env("resolve:OAUTH2_SERVER_PRIVATE_KEY_PATH")` |
35
+ | `private_key_passphrase` | `""` | Its pass phrase |
36
+ | `encryption_key` | `None` | Seals codes and refresh tokens |
37
+ | `encryption_key_type` | `"plain"` | `"plain"`: a password of 32+ characters, stretched once (PBKDF2-HMAC-SHA256). `"key"`: base64url of 32 random bytes |
38
+ | `audience` | `None` | `aud` of every access token, and required by the resource server; `None` addresses a token to its client and checks nothing |
39
+ | `signature_algorithm` | `"RS256"` | `RS*`, `PS*`, `ES*`, `EdDSA` |
40
+ | `additional_public_keys` | `()` | Files of further keys tokens verify against, and the key set publishes |
41
+ | `access_token_ttl` | `timedelta(hours=1)` | Built-in grants issue for this; a tagged grant may carry its own |
42
+ | `refresh_token_ttl` | `timedelta(days=30)` | |
43
+ | `auth_code_ttl` | `timedelta(minutes=10)` | |
44
+ | `device_code_ttl` | `timedelta(minutes=10)` | |
45
+ | `enable_client_credentials_grant` | `True` | |
46
+ | `enable_auth_code_grant` | `True` | |
47
+ | `enable_refresh_token_grant` | `True` | |
48
+ | `enable_device_code_grant` | `True` | Needs `device_code_verification_uri` |
49
+ | `enable_password_grant` | `False` | Answers through `OAuth2Events.USER_RESOLVE` |
50
+ | `enable_implicit_grant` | `False` | |
51
+ | `require_code_challenge_for_public_clients` | `True` | |
52
+ | `persist_access_token` | `True` | Off: no access-token store, nothing revocable before expiry |
53
+ | `revoke_refresh_tokens` | `True` | A redeemed refresh token is spent (rotation) |
54
+ | `response_type_class` | `None` | A `ResponseTypeInterface` class the container builds; `BearerTokenResponse` otherwise |
55
+ | `device_code_verification_uri` | `""` | The application's page for the user code |
56
+ | `enable_device_code_verification_uri_complete_generation` | `True` | |
57
+ | `device_code_polling_interval` | `5` | Seconds; not negative |
58
+ | `enable_device_code_polling_interval_visibility` | `True` | |
59
+ | `enable_revocation_endpoint` | `True` | `404` when off |
60
+ | `enable_introspection_endpoint` | `False` | `404` when off |
61
+ | `enable_metadata_endpoint` | `True` | `404` when off |
62
+
63
+ ## `ResourceServerConfig` and `ScopesConfig`
64
+
65
+ | Field | Default | What it is |
66
+ | --- | --- | --- |
67
+ | `ResourceServerConfig.public_key` | `None` | The verifying key; derived from `private_key` when `None` — set it for an API running apart from the server |
68
+ | `ResourceServerConfig.jwt_leeway` | `None` | A `timedelta` widening a token's window; not negative |
69
+ | `ScopesConfig.available` | `()` | Every scope a client may ask for; empty is reported as missing |
70
+ | `ScopesConfig.default` | `()` | Given to a client registered with no scopes (the `PRE_SAVE_CLIENT` listener); within `available`; may be empty |
71
+
72
+ A token request naming no scope is granted the client's registered scopes; a client with no
73
+ scopes may ask for any.
74
+
75
+ ## Changing one environment
76
+
77
+ ```python
78
+ from dataclasses import replace
79
+
80
+ from xtr_dependency_injection import configure, when
81
+ from xtr_security_oauth2_server.bundle import OAuth2ServerConfig
82
+
83
+
84
+ @configure
85
+ @when("prod")
86
+ def introspection_in_production(config: OAuth2ServerConfig) -> OAuth2ServerConfig:
87
+ return replace(
88
+ config,
89
+ authorization_server=replace(
90
+ config.authorization_server, enable_introspection_endpoint=True
91
+ ),
92
+ )
93
+ ```
94
+
95
+ ## Keys
96
+
97
+ - `oauth2-server:generate-keypair [ALGORITHM]` writes the file `private_key` names (and
98
+ `resource_server.public_key`'s, when set); `--dry-run` prints, `--skip-if-exists` and
99
+ `--overwrite` decide about a file in place. It refuses when `private_key` is key text or unset.
100
+ - The encryption key is minted by hand:
101
+ `python -c "import base64, secrets; print(base64.urlsafe_b64encode(secrets.token_bytes(32)).decode().rstrip('='))"`.
102
+ - This server's `RawKeyLoader` and `JoserfcJwsProvider` are registered under
103
+ `Target("oauth2_server")`, never the bare interfaces `JwtBundle` owns. Rotate by adding the old
104
+ public key under `additional_public_keys` before swapping `private_key`.
@@ -0,0 +1,105 @@
1
+ # Events
2
+
3
+ Every event is dispatched **under a name**, and a listener registers under the very same name:
4
+ `@as_event_listener(OAuth2Events.X)` from `xtr_event_dispatcher`. Registering under the event
5
+ class instead reaches nothing, silently. The dispatcher reads a listener's annotation at runtime,
6
+ so import the event class at module level, never under `TYPE_CHECKING`.
7
+
8
+ ## The bundle's six (`xtr_security_oauth2_server.oauth2_events.OAuth2Events`)
9
+
10
+ | Name | Event (`xtr_security_oauth2_server.event.…`) | What a listener does |
11
+ | --- | --- | --- |
12
+ | `AUTHORIZATION_REQUEST_RESOLVE` | `AuthorizationRequestResolveEvent` | `resolve_authorization(APPROVED or DENIED)`, or `set_response(page)`; either stops later listeners. Denied until resolved |
13
+ | `SCOPE_RESOLVE` | `ScopeResolveEvent` | `set_scopes(*scopes)` — the last word on what a token carries; `get_grant()`, `get_client()`, `get_user_identifier()` |
14
+ | `USER_RESOLVE` | `UserResolveEvent` | The password grant only: check `get_username()` / `get_password()`, `set_user(user)`; unset refuses |
15
+ | `ACCESS_TOKEN_EXTRA_CLAIMS_RESOLVE` | `AccessTokenExtraClaimsResolveEvent` | `set_extra_claims({...})` signed into the token; the token's own claim names are refused |
16
+ | `TOKEN_REQUEST_RESOLVE` | `TokenRequestResolveEvent` | `set_response(...)` replacing the token answer, refusals included |
17
+ | `PRE_SAVE_CLIENT` | `PreSaveClientEvent` | Change `get_client()`, or `set_client(other)`, before a manager stores it |
18
+
19
+ ```python
20
+ from xtr_event_dispatcher import as_event_listener
21
+ from xtr_security_oauth2_server.event.access_token_extra_claims_resolve_event import (
22
+ AccessTokenExtraClaimsResolveEvent,
23
+ )
24
+ from xtr_security_oauth2_server.event.pre_save_client_event import PreSaveClientEvent
25
+ from xtr_security_oauth2_server.event.scope_resolve_event import ScopeResolveEvent
26
+ from xtr_security_oauth2_server.event.token_request_resolve_event import TokenRequestResolveEvent
27
+ from xtr_security_oauth2_server.oauth2_events import OAuth2Events
28
+
29
+
30
+ @as_event_listener(OAuth2Events.SCOPE_RESOLVE)
31
+ async def no_writes_for_services(event: ScopeResolveEvent) -> None:
32
+ if event.get_user_identifier() is None: # a client acting for itself
33
+ _ = event.set_scopes(
34
+ *(scope for scope in event.get_scopes() if str(scope) != "invoices:write")
35
+ )
36
+
37
+
38
+ @as_event_listener(OAuth2Events.ACCESS_TOKEN_EXTRA_CLAIMS_RESOLVE)
39
+ async def add_tenant(event: AccessTokenExtraClaimsResolveEvent) -> None:
40
+ _ = event.set_extra_claims({"tenant": "acme"})
41
+
42
+
43
+ @as_event_listener(OAuth2Events.TOKEN_REQUEST_RESOLVE)
44
+ async def mark_the_answer(event: TokenRequestResolveEvent) -> None:
45
+ _ = event.set_response(event.get_response().with_header("x-issued-by", "invoicing"))
46
+
47
+
48
+ @as_event_listener(OAuth2Events.PRE_SAVE_CLIENT)
49
+ async def deactivate_untrusted(event: PreSaveClientEvent) -> None:
50
+ client = event.get_client()
51
+ if client.get_name().startswith("Untrusted"):
52
+ _ = client.set_active(False)
53
+ ```
54
+
55
+ The password grant's user check:
56
+
57
+ ```python
58
+ from xtr_event_dispatcher import as_event_listener
59
+ from xtr_security_core.user.in_memory_user import InMemoryUser
60
+ from xtr_security_oauth2_server.event.user_resolve_event import UserResolveEvent
61
+ from xtr_security_oauth2_server.oauth2_events import OAuth2Events
62
+
63
+
64
+ @as_event_listener(OAuth2Events.USER_RESOLVE)
65
+ async def resolve_user(event: UserResolveEvent) -> None:
66
+ if event.get_username() != "ada" or event.get_password() != "the-password": # check a hash
67
+ return
68
+ _ = event.set_user(InMemoryUser("ada", roles=["ROLE_USER"]))
69
+ ```
70
+
71
+ The shipped `AddClientDefaultScopesListener` is the `PRE_SAVE_CLIENT` listener that gives a
72
+ client registered with no scopes `scopes.default`.
73
+
74
+ ## Consent, in two passes
75
+
76
+ The first pass answers the browser with a page; the form posts the original query back to
77
+ `/authorize`; the second pass approves. Nothing is remembered between them, so the listener tells
78
+ them apart from the request — a field of its form — and must check a CSRF token bound to
79
+ `event.get_user()` before approving. `event.is_authorization_approved()` reports the request's own
80
+ flag, `event.get_scopes()` the stored scopes asked for, `event.get_client()` the stored client.
81
+
82
+ ## The protocol library's five (`xtr_security_oauth2_server_core.request_event.RequestEvent`)
83
+
84
+ | Name | Event | When |
85
+ | --- | --- | --- |
86
+ | `ACCESS_TOKEN_ISSUED` | `RequestAccessTokenEvent` (`get_access_token()`) | Any grant issued an access token |
87
+ | `REFRESH_TOKEN_ISSUED` | `RequestRefreshTokenEvent` | A refresh token was issued |
88
+ | `CLIENT_AUTHENTICATION_FAILED` | `RequestEvent` | A client failed to authenticate |
89
+ | `USER_AUTHENTICATION_FAILED` | `RequestEvent` | The password grant refused a user |
90
+ | `REFRESH_TOKEN_CLIENT_FAILED` | `RequestEvent` | A refresh token came from another client |
91
+
92
+ Each carries `get_name()` and `get_request()`.
93
+
94
+ ```python
95
+ from xtr_event_dispatcher import as_event_listener
96
+ from xtr_security_oauth2_server_core.request_access_token_event import RequestAccessTokenEvent
97
+ from xtr_security_oauth2_server_core.request_event import RequestEvent
98
+
99
+
100
+ @as_event_listener(RequestEvent.ACCESS_TOKEN_ISSUED)
101
+ async def audit(event: RequestAccessTokenEvent) -> None:
102
+ print("issued to", event.get_access_token().get_client().get_identifier())
103
+ ```
104
+
105
+ Count `CLIENT_AUTHENTICATION_FAILED` to throttle a client guessing secrets.