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.
- xtr_security_oauth2_server/.agents/skills/xtr-security-oauth2-server/SKILL.md +373 -0
- xtr_security_oauth2_server/.agents/skills/xtr-security-oauth2-server/references/configuration.md +104 -0
- xtr_security_oauth2_server/.agents/skills/xtr-security-oauth2-server/references/events.md +105 -0
- xtr_security_oauth2_server/.agents/skills/xtr-security-oauth2-server/references/extending.md +199 -0
- xtr_security_oauth2_server/.agents/skills/xtr-security-oauth2-server/references/persistence.md +122 -0
- xtr_security_oauth2_server/__init__.py +14 -0
- xtr_security_oauth2_server/_endpoint_paths.py +82 -0
- xtr_security_oauth2_server/_service_qualifier.py +18 -0
- xtr_security_oauth2_server/authorization_server_metadata.py +174 -0
- xtr_security_oauth2_server/bundle/__init__.py +43 -0
- xtr_security_oauth2_server/bundle/_application_stores.py +82 -0
- xtr_security_oauth2_server/bundle/_command_wiring.py +74 -0
- xtr_security_oauth2_server/bundle/_endpoint_wiring.py +79 -0
- xtr_security_oauth2_server/bundle/_grant_factories.py +210 -0
- xtr_security_oauth2_server/bundle/_metadata_factories.py +163 -0
- xtr_security_oauth2_server/bundle/_no_persistence.py +68 -0
- xtr_security_oauth2_server/bundle/_parameters.py +42 -0
- xtr_security_oauth2_server/bundle/_persistence_factories.py +168 -0
- xtr_security_oauth2_server/bundle/_persistence_wiring.py +272 -0
- xtr_security_oauth2_server/bundle/_required_settings.py +87 -0
- xtr_security_oauth2_server/bundle/_server_factories.py +226 -0
- xtr_security_oauth2_server/bundle/_service_factories.py +219 -0
- xtr_security_oauth2_server/bundle/_service_wiring.py +230 -0
- xtr_security_oauth2_server/bundle/_signing_factories.py +151 -0
- xtr_security_oauth2_server/bundle/authorization_server_config.py +205 -0
- xtr_security_oauth2_server/bundle/client_config.py +63 -0
- xtr_security_oauth2_server/bundle/encryption_key_pass.py +79 -0
- xtr_security_oauth2_server/bundle/grant_type_pass.py +149 -0
- xtr_security_oauth2_server/bundle/metadata_pass.py +104 -0
- xtr_security_oauth2_server/bundle/oauth2_authenticator_config.py +27 -0
- xtr_security_oauth2_server/bundle/oauth2_server_bundle.py +191 -0
- xtr_security_oauth2_server/bundle/oauth2_server_config.py +85 -0
- xtr_security_oauth2_server/bundle/persistence_configs.py +99 -0
- xtr_security_oauth2_server/bundle/resource_server_config.py +50 -0
- xtr_security_oauth2_server/bundle/scopes_config.py +68 -0
- xtr_security_oauth2_server/command/__init__.py +19 -0
- xtr_security_oauth2_server/command/_key_files.py +85 -0
- xtr_security_oauth2_server/command/_key_paths.py +73 -0
- xtr_security_oauth2_server/command/_registry.py +16 -0
- xtr_security_oauth2_server/command/clear_expired_tokens_command.py +122 -0
- xtr_security_oauth2_server/command/create_client_command.py +220 -0
- xtr_security_oauth2_server/command/delete_client_command.py +49 -0
- xtr_security_oauth2_server/command/generate_key_pair_command.py +230 -0
- xtr_security_oauth2_server/command/list_clients_command.py +133 -0
- xtr_security_oauth2_server/command/update_client_command.py +178 -0
- xtr_security_oauth2_server/controller/__init__.py +28 -0
- xtr_security_oauth2_server/controller/_served_response.py +53 -0
- xtr_security_oauth2_server/controller/authorization_controller.py +264 -0
- xtr_security_oauth2_server/controller/device_code_controller.py +67 -0
- xtr_security_oauth2_server/controller/introspection_controller.py +70 -0
- xtr_security_oauth2_server/controller/jwks_controller.py +66 -0
- xtr_security_oauth2_server/controller/metadata_controller.py +111 -0
- xtr_security_oauth2_server/controller/revocation_controller.py +71 -0
- xtr_security_oauth2_server/controller/token_controller.py +74 -0
- xtr_security_oauth2_server/converter/__init__.py +22 -0
- xtr_security_oauth2_server/converter/scope_converter.py +55 -0
- xtr_security_oauth2_server/converter/scope_converter_interface.py +47 -0
- xtr_security_oauth2_server/converter/user_converter.py +43 -0
- xtr_security_oauth2_server/converter/user_converter_interface.py +30 -0
- xtr_security_oauth2_server/entity/__init__.py +33 -0
- xtr_security_oauth2_server/entity/access_token.py +35 -0
- xtr_security_oauth2_server/entity/auth_code.py +27 -0
- xtr_security_oauth2_server/entity/client.py +76 -0
- xtr_security_oauth2_server/entity/device_code.py +27 -0
- xtr_security_oauth2_server/entity/refresh_token.py +26 -0
- xtr_security_oauth2_server/entity/scope.py +24 -0
- xtr_security_oauth2_server/entity/user.py +23 -0
- xtr_security_oauth2_server/event/__init__.py +28 -0
- xtr_security_oauth2_server/event/access_token_extra_claims_resolve_event.py +86 -0
- xtr_security_oauth2_server/event/authorization_request_resolve_event.py +153 -0
- xtr_security_oauth2_server/event/authorization_request_resolve_event_factory.py +89 -0
- xtr_security_oauth2_server/event/pre_save_client_event.py +47 -0
- xtr_security_oauth2_server/event/scope_resolve_event.py +80 -0
- xtr_security_oauth2_server/event/token_request_resolve_event.py +51 -0
- xtr_security_oauth2_server/event/user_resolve_event.py +82 -0
- xtr_security_oauth2_server/event_listener/__init__.py +16 -0
- xtr_security_oauth2_server/event_listener/add_client_default_scopes_listener.py +60 -0
- xtr_security_oauth2_server/exception/__init__.py +21 -0
- xtr_security_oauth2_server/exception/client_already_registered_error.py +29 -0
- xtr_security_oauth2_server/exception/invalid_configuration_error.py +32 -0
- xtr_security_oauth2_server/factory/__init__.py +12 -0
- xtr_security_oauth2_server/factory/oauth2_factory.py +130 -0
- xtr_security_oauth2_server/manager/__init__.py +41 -0
- xtr_security_oauth2_server/manager/access_token_manager_interface.py +36 -0
- xtr_security_oauth2_server/manager/authorization_code_manager_interface.py +35 -0
- xtr_security_oauth2_server/manager/client_filter.py +79 -0
- xtr_security_oauth2_server/manager/client_manager_interface.py +51 -0
- xtr_security_oauth2_server/manager/device_code_manager_interface.py +38 -0
- xtr_security_oauth2_server/manager/in_memory/__init__.py +29 -0
- xtr_security_oauth2_server/manager/in_memory/access_token_manager.py +82 -0
- xtr_security_oauth2_server/manager/in_memory/authorization_code_manager.py +68 -0
- xtr_security_oauth2_server/manager/in_memory/client_manager.py +95 -0
- xtr_security_oauth2_server/manager/in_memory/device_code_manager.py +73 -0
- xtr_security_oauth2_server/manager/in_memory/refresh_token_manager.py +80 -0
- xtr_security_oauth2_server/manager/in_memory/scope_manager.py +45 -0
- xtr_security_oauth2_server/manager/null/__init__.py +12 -0
- xtr_security_oauth2_server/manager/null/access_token_manager.py +56 -0
- xtr_security_oauth2_server/manager/orm/__init__.py +31 -0
- xtr_security_oauth2_server/manager/orm/_changed_rows.py +20 -0
- xtr_security_oauth2_server/manager/orm/access_token_manager.py +133 -0
- xtr_security_oauth2_server/manager/orm/authorization_code_manager.py +83 -0
- xtr_security_oauth2_server/manager/orm/client_manager.py +195 -0
- xtr_security_oauth2_server/manager/orm/device_code_manager.py +91 -0
- xtr_security_oauth2_server/manager/orm/refresh_token_manager.py +114 -0
- xtr_security_oauth2_server/manager/refresh_token_manager_interface.py +48 -0
- xtr_security_oauth2_server/manager/scope_manager_interface.py +28 -0
- xtr_security_oauth2_server/metadata_contributor_interface.py +44 -0
- xtr_security_oauth2_server/model/__init__.py +49 -0
- xtr_security_oauth2_server/model/_short_identifier.py +19 -0
- xtr_security_oauth2_server/model/abstract_client.py +152 -0
- xtr_security_oauth2_server/model/access_token.py +92 -0
- xtr_security_oauth2_server/model/access_token_interface.py +65 -0
- xtr_security_oauth2_server/model/authorization_code.py +91 -0
- xtr_security_oauth2_server/model/authorization_code_interface.py +65 -0
- xtr_security_oauth2_server/model/client.py +18 -0
- xtr_security_oauth2_server/model/client_interface.py +103 -0
- xtr_security_oauth2_server/model/device_code.py +167 -0
- xtr_security_oauth2_server/model/device_code_interface.py +100 -0
- xtr_security_oauth2_server/model/refresh_token.py +79 -0
- xtr_security_oauth2_server/model/refresh_token_interface.py +55 -0
- xtr_security_oauth2_server/null_metadata_contributor.py +45 -0
- xtr_security_oauth2_server/oauth2_events.py +69 -0
- xtr_security_oauth2_server/persistence/__init__.py +15 -0
- xtr_security_oauth2_server/persistence/mapping/__init__.py +20 -0
- xtr_security_oauth2_server/persistence/mapping/_fields.py +85 -0
- xtr_security_oauth2_server/persistence/mapping/_tables.py +259 -0
- xtr_security_oauth2_server/persistence/mapping/client_mapping_mixin.py +72 -0
- xtr_security_oauth2_server/persistence/mapping/driver.py +280 -0
- xtr_security_oauth2_server/persistence/mapping/mapped_table.py +28 -0
- xtr_security_oauth2_server/persistence/type/__init__.py +26 -0
- xtr_security_oauth2_server/persistence/type/grant.py +30 -0
- xtr_security_oauth2_server/persistence/type/imploded_array.py +81 -0
- xtr_security_oauth2_server/persistence/type/redirect_uri.py +34 -0
- xtr_security_oauth2_server/persistence/type/scope.py +34 -0
- xtr_security_oauth2_server/persistence/type/utc_datetime.py +65 -0
- xtr_security_oauth2_server/py.typed +0 -0
- xtr_security_oauth2_server/recipe/__init__.py +19 -0
- xtr_security_oauth2_server/recipe/files/config/oauth2_server.py.tmpl +63 -0
- xtr_security_oauth2_server/recipe/files/config/routes/oauth2_server.py.tmpl +27 -0
- xtr_security_oauth2_server/recipe/manifest.toml +53 -0
- xtr_security_oauth2_server/repository/__init__.py +39 -0
- xtr_security_oauth2_server/repository/_stored_client.py +47 -0
- xtr_security_oauth2_server/repository/access_token_repository.py +165 -0
- xtr_security_oauth2_server/repository/auth_code_repository.py +116 -0
- xtr_security_oauth2_server/repository/client_repository.py +150 -0
- xtr_security_oauth2_server/repository/device_code_repository.py +207 -0
- xtr_security_oauth2_server/repository/null_access_token_repository.py +90 -0
- xtr_security_oauth2_server/repository/refresh_token_repository.py +114 -0
- xtr_security_oauth2_server/repository/scope_repository.py +148 -0
- xtr_security_oauth2_server/repository/user_repository.py +92 -0
- xtr_security_oauth2_server/routing/__init__.py +14 -0
- xtr_security_oauth2_server/routing/_endpoints.py +118 -0
- xtr_security_oauth2_server/routing/oauth2_server_route_loader.py +52 -0
- xtr_security_oauth2_server/routing/well_known_route_loader.py +32 -0
- xtr_security_oauth2_server/security/__init__.py +22 -0
- xtr_security_oauth2_server/security/authentication/__init__.py +7 -0
- xtr_security_oauth2_server/security/authentication/token/__init__.py +7 -0
- xtr_security_oauth2_server/security/authentication/token/oauth2_token.py +117 -0
- xtr_security_oauth2_server/security/authenticator/__init__.py +7 -0
- xtr_security_oauth2_server/security/authenticator/oauth2_authenticator.py +277 -0
- xtr_security_oauth2_server/security/exception/__init__.py +16 -0
- xtr_security_oauth2_server/security/exception/oauth2_authentication_error.py +93 -0
- xtr_security_oauth2_server/security/exception/oauth2_authentication_failed_error.py +44 -0
- xtr_security_oauth2_server/security/user/__init__.py +7 -0
- xtr_security_oauth2_server/security/user/client_credentials_user.py +53 -0
- xtr_security_oauth2_server/service/__init__.py +16 -0
- xtr_security_oauth2_server/service/credentials_revoker/__init__.py +12 -0
- xtr_security_oauth2_server/service/credentials_revoker/orm_credentials_revoker.py +152 -0
- xtr_security_oauth2_server/service/credentials_revoker_interface.py +41 -0
- xtr_security_oauth2_server/value_object/__init__.py +21 -0
- xtr_security_oauth2_server/value_object/grant.py +45 -0
- xtr_security_oauth2_server/value_object/redirect_uri.py +54 -0
- xtr_security_oauth2_server/value_object/scope.py +46 -0
- xtr_security_oauth2_server-4.0.2.dist-info/METADATA +667 -0
- xtr_security_oauth2_server-4.0.2.dist-info/RECORD +178 -0
- xtr_security_oauth2_server-4.0.2.dist-info/WHEEL +4 -0
- xtr_security_oauth2_server-4.0.2.dist-info/entry_points.txt +6 -0
- 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.
|
xtr_security_oauth2_server/.agents/skills/xtr-security-oauth2-server/references/configuration.md
ADDED
|
@@ -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.
|