xtr-security-http 3.0.0__tar.gz → 4.0.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/LICENSE +1 -0
  2. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/PKG-INFO +14 -12
  3. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/README.md +6 -6
  4. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/pyproject.toml +8 -8
  5. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/pyproject.toml.orig +8 -8
  6. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/__init__.py +30 -0
  7. xtr_security_http-4.0.2/src/xtr_security_http/_challenge.py +64 -0
  8. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/_runner.py +23 -6
  9. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/_state.py +4 -3
  10. xtr_security_http-4.0.2/src/xtr_security_http/access_token/form_encoded_body_extractor.py +120 -0
  11. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/access_token/oidc/oidc_token_handler.py +4 -4
  12. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/authenticator/access_token_authenticator.py +13 -3
  13. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/authenticator/passport/passport.py +11 -2
  14. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/authenticator/token/post_authentication_token.py +10 -2
  15. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/authorization/insufficient_scope_access_denied_handler.py +9 -4
  16. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/authorization/oauth2_scope_voter.py +11 -6
  17. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/decorator/current_user.py +1 -2
  18. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/event/__init__.py +2 -1
  19. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/event_listener/check_credentials_listener.py +10 -21
  20. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/firewall/exception_listener.py +7 -2
  21. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/firewall/firewall.py +1 -1
  22. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/firewall_context_interface.py +2 -5
  23. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/firewall_scheme_registry.py +0 -4
  24. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/oidc/oidc_discovery.py +100 -20
  25. xtr_security_http-4.0.2/src/xtr_security_http/request_matcher/host_request_matcher.py +51 -0
  26. xtr_security_http-4.0.2/src/xtr_security_http/request_matcher/ip_request_matcher.py +61 -0
  27. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/request_matcher/path_request_matcher.py +3 -1
  28. xtr_security_http-3.0.0/src/xtr_security_http/access_token/form_encoded_body_extractor.py +0 -57
  29. xtr_security_http-3.0.0/src/xtr_security_http/request_matcher/host_request_matcher.py +0 -43
  30. xtr_security_http-3.0.0/src/xtr_security_http/request_matcher/ip_request_matcher.py +0 -37
  31. xtr_security_http-3.0.0/src/xtr_security_http/security_events.py +0 -41
  32. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/access_map.py +0 -0
  33. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/access_map_interface.py +0 -0
  34. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/access_token/__init__.py +0 -0
  35. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/access_token/access_token_extractor_interface.py +0 -0
  36. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/access_token/access_token_handler_interface.py +0 -0
  37. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/access_token/chain_access_token_extractor.py +0 -0
  38. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/access_token/header_access_token_extractor.py +0 -0
  39. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/access_token/oidc/__init__.py +0 -0
  40. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/access_token/oidc/exception/__init__.py +0 -0
  41. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/access_token/oidc/exception/oidc_key_set_error.py +0 -0
  42. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/access_token/query_access_token_extractor.py +0 -0
  43. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/authentication/__init__.py +0 -0
  44. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/authentication/_sensitive.py +0 -0
  45. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/authentication/authentication_failure_handler_interface.py +0 -0
  46. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/authentication/authentication_success_handler_interface.py +0 -0
  47. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/authentication/authenticator_manager.py +0 -0
  48. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/authentication/authenticator_manager_interface.py +0 -0
  49. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/authentication/expose_security_level.py +0 -0
  50. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/authenticator/__init__.py +0 -0
  51. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/authenticator/abstract_authenticator.py +0 -0
  52. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/authenticator/authenticator_interface.py +0 -0
  53. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/authenticator/oidc/__init__.py +0 -0
  54. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/authenticator/oidc/oidc_jwks.py +0 -0
  55. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/authenticator/passport/__init__.py +0 -0
  56. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/authenticator/passport/badge/__init__.py +0 -0
  57. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/authenticator/passport/badge/badge_interface.py +0 -0
  58. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/authenticator/passport/badge/password_upgrade_badge.py +0 -0
  59. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/authenticator/passport/badge/pre_authenticated_user_badge.py +0 -0
  60. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/authenticator/passport/badge/user_badge.py +0 -0
  61. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/authenticator/passport/credentials/__init__.py +0 -0
  62. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/authenticator/passport/credentials/credentials_interface.py +0 -0
  63. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/authenticator/passport/credentials/custom_credentials.py +0 -0
  64. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/authenticator/passport/credentials/password_credentials.py +0 -0
  65. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/authenticator/passport/self_validating_passport.py +0 -0
  66. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/authenticator/token/__init__.py +0 -0
  67. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/authorization/__init__.py +0 -0
  68. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/authorization/access_denied_handler_interface.py +0 -0
  69. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/decorator/__init__.py +0 -0
  70. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/decorator/is_granted.py +0 -0
  71. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/decorator/is_granted_context.py +0 -0
  72. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/entry_point/__init__.py +0 -0
  73. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/entry_point/authentication_entry_point_interface.py +0 -0
  74. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/event/authentication_token_created_event.py +0 -0
  75. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/event/check_passport_event.py +0 -0
  76. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/event/login_failure_event.py +0 -0
  77. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/event/login_success_event.py +0 -0
  78. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/event_listener/__init__.py +0 -0
  79. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/event_listener/password_migrating_listener.py +0 -0
  80. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/event_listener/user_checker_listener.py +0 -0
  81. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/event_listener/user_provider_listener.py +0 -0
  82. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/exception/__init__.py +0 -0
  83. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/exception/firewall_not_booted_error.py +0 -0
  84. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/exception/invalid_access_token_error.py +0 -0
  85. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/exception/unknown_firewall_error.py +0 -0
  86. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/firewall/__init__.py +0 -0
  87. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/firewall/access_listener.py +0 -0
  88. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/firewall_map.py +0 -0
  89. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/firewall_map_interface.py +0 -0
  90. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/firewall_scheme.py +0 -0
  91. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/oidc/__init__.py +0 -0
  92. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/py.typed +0 -0
  93. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/request_matcher/__init__.py +0 -0
  94. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/request_matcher/_pattern.py +0 -0
  95. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/request_matcher/callable_request_matcher.py +0 -0
  96. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/request_matcher/chain_request_matcher.py +0 -0
  97. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/request_matcher/method_request_matcher.py +0 -0
  98. {xtr_security_http-3.0.0 → xtr_security_http-4.0.2}/src/xtr_security_http/request_matcher/request_matcher_interface.py +0 -0
@@ -1,6 +1,7 @@
1
1
  MIT License
2
2
 
3
3
  Copyright (c) 2026 xterr
4
+ Copyright (c) 2004-present Fabien Potencier
4
5
 
5
6
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
7
  of this software and associated documentation files (the "Software"), to deal
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: xtr-security-http
3
- Version: 3.0.0
3
+ Version: 4.0.2
4
4
  Summary: The HTTP edge of xtr security: firewalls, authenticators, bearer access tokens and the request surface.
5
5
  Keywords: security,authentication,firewall,fastapi,oauth2
6
6
  Author: Xterr
@@ -14,11 +14,13 @@ Classifier: Programming Language :: Python :: 3.12
14
14
  Classifier: Programming Language :: Python :: 3.13
15
15
  Classifier: Programming Language :: Python :: 3.14
16
16
  Classifier: Typing :: Typed
17
- Requires-Dist: xtr-security-core>=3.0,<4
18
- Requires-Dist: xtr-http-kernel>=3.0,<4
19
- Requires-Dist: xtr-event-dispatcher-contracts>=3.0,<4
20
- Requires-Dist: xtr-logging-contracts>=3.0,<4
21
- Requires-Dist: xtr-password-hasher>=3.0,<4
17
+ Requires-Dist: xtr-security-core>=4.0,<5
18
+ Requires-Dist: xtr-http-kernel>=4.0,<5
19
+ Requires-Dist: xtr-dependency-injection[fastapi]>=4.0,<5
20
+ Requires-Dist: xtr-event-dispatcher>=4.0,<5
21
+ Requires-Dist: xtr-event-dispatcher-contracts>=4.0,<5
22
+ Requires-Dist: xtr-logging-contracts>=4.0,<5
23
+ Requires-Dist: xtr-password-hasher>=4.0,<5
22
24
  Requires-Dist: fastapi>=0.123
23
25
  Requires-Dist: starlette>=0.40
24
26
  Requires-Dist: anyio>=4.0
@@ -182,7 +184,7 @@ routes. See [xtr-security's Quick start](../xtr-security#quick-start) for the wh
182
184
  ### Firewalls and the map
183
185
 
184
186
  A **`FirewallContextInterface`** is one firewall's runtime pieces: its `name`, its
185
- `authenticator_manager`, its `access_listener`, its own event `dispatcher`, the FastAPI `scheme`
187
+ `authenticator_manager`, its `access_listener`, the FastAPI `scheme`
186
188
  it shows in OpenAPI, whether it has `security` at all, and the optional `entry_point`,
187
189
  `access_denied_handler` and `scope_denied_handler` that answer a failure. A **`FirewallMap`**
188
190
  holds those contexts: `match(request)` returns the first whose matcher claims the request,
@@ -363,9 +365,10 @@ answers a denied scope with a `403` carrying the RFC 6750 `insufficient_scope` c
363
365
 
364
366
  ### Security events
365
367
 
366
- The event name constants a dispatcher keys on live in `security_events.py`: `CHECK_PASSPORT`,
367
- `AUTHENTICATION_TOKEN_CREATED`, `LOGIN_SUCCESS`, `LOGIN_FAILURE`. A listener names the constant
368
- rather than importing the event class, so a listener chosen at runtime stays in step.
368
+ Four events are raised around a request's authentication — `CheckPassportEvent`,
369
+ `AuthenticationTokenCreatedEvent`, `LoginSuccessEvent`, `LoginFailureEvent`. A subscriber listens
370
+ to the event class itself (the dispatcher derives each event's name from its class), so there is no
371
+ separate table of name constants to keep in step.
369
372
 
370
373
  ## Errors
371
374
 
@@ -394,10 +397,9 @@ xtr_security_http/
394
397
  ├── access_map.py the ordered access-control rules
395
398
  ├── request_matcher/ path, host, method, ip, chain, callable
396
399
  ├── authorization/ OAuth2ScopeVoter, oauth2_scope, the scope-denied handler
397
- ├── decorator/ Firewall, IsGranted, CurrentUser, IsGrantedContext
400
+ ├── decorator/ IsGranted, CurrentUser, IsGrantedContext
398
401
  ├── entry_point/ AuthenticationEntryPointInterface
399
402
  ├── event/ the four firewall events
400
- ├── security_events.py the name each is dispatched under
401
403
  ├── event_listener/ the listeners the bundle registers (incl. the timing guard)
402
404
  └── exception/ the errors this package adds
403
405
  ```
@@ -151,7 +151,7 @@ routes. See [xtr-security's Quick start](../xtr-security#quick-start) for the wh
151
151
  ### Firewalls and the map
152
152
 
153
153
  A **`FirewallContextInterface`** is one firewall's runtime pieces: its `name`, its
154
- `authenticator_manager`, its `access_listener`, its own event `dispatcher`, the FastAPI `scheme`
154
+ `authenticator_manager`, its `access_listener`, the FastAPI `scheme`
155
155
  it shows in OpenAPI, whether it has `security` at all, and the optional `entry_point`,
156
156
  `access_denied_handler` and `scope_denied_handler` that answer a failure. A **`FirewallMap`**
157
157
  holds those contexts: `match(request)` returns the first whose matcher claims the request,
@@ -332,9 +332,10 @@ answers a denied scope with a `403` carrying the RFC 6750 `insufficient_scope` c
332
332
 
333
333
  ### Security events
334
334
 
335
- The event name constants a dispatcher keys on live in `security_events.py`: `CHECK_PASSPORT`,
336
- `AUTHENTICATION_TOKEN_CREATED`, `LOGIN_SUCCESS`, `LOGIN_FAILURE`. A listener names the constant
337
- rather than importing the event class, so a listener chosen at runtime stays in step.
335
+ Four events are raised around a request's authentication — `CheckPassportEvent`,
336
+ `AuthenticationTokenCreatedEvent`, `LoginSuccessEvent`, `LoginFailureEvent`. A subscriber listens
337
+ to the event class itself (the dispatcher derives each event's name from its class), so there is no
338
+ separate table of name constants to keep in step.
338
339
 
339
340
  ## Errors
340
341
 
@@ -363,10 +364,9 @@ xtr_security_http/
363
364
  ├── access_map.py the ordered access-control rules
364
365
  ├── request_matcher/ path, host, method, ip, chain, callable
365
366
  ├── authorization/ OAuth2ScopeVoter, oauth2_scope, the scope-denied handler
366
- ├── decorator/ Firewall, IsGranted, CurrentUser, IsGrantedContext
367
+ ├── decorator/ IsGranted, CurrentUser, IsGrantedContext
367
368
  ├── entry_point/ AuthenticationEntryPointInterface
368
369
  ├── event/ the four firewall events
369
- ├── security_events.py the name each is dispatched under
370
370
  ├── event_listener/ the listeners the bundle registers (incl. the timing guard)
371
371
  └── exception/ the errors this package adds
372
372
  ```
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "xtr-security-http"
3
- version = "3.0.0"
3
+ version = "4.0.2"
4
4
  description = "The HTTP edge of xtr security: firewalls, authenticators, bearer access tokens and the request surface."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -23,11 +23,13 @@ classifiers = [
23
23
  "Typing :: Typed",
24
24
  ]
25
25
  dependencies = [
26
- "xtr-security-core>=3.0,<4",
27
- "xtr-http-kernel>=3.0,<4",
28
- "xtr-event-dispatcher-contracts>=3.0,<4",
29
- "xtr-logging-contracts>=3.0,<4",
30
- "xtr-password-hasher>=3.0,<4",
26
+ "xtr-security-core>=4.0,<5",
27
+ "xtr-http-kernel>=4.0,<5",
28
+ "xtr-dependency-injection[fastapi]>=4.0,<5",
29
+ "xtr-event-dispatcher>=4.0,<5",
30
+ "xtr-event-dispatcher-contracts>=4.0,<5",
31
+ "xtr-logging-contracts>=4.0,<5",
32
+ "xtr-password-hasher>=4.0,<5",
31
33
  "fastapi>=0.123",
32
34
  "starlette>=0.40",
33
35
  "anyio>=4.0",
@@ -52,8 +54,6 @@ dev = [
52
54
  "pytest-cov>=5",
53
55
  "anyio>=4.0",
54
56
  "ty>=0.0.83",
55
- "xtr-dependency-injection>=3.0,<4",
56
- "xtr-event-dispatcher>=3.0,<4",
57
57
  "httpx>=0.27",
58
58
  "joserfc>=1.7",
59
59
  ]
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "xtr-security-http"
3
- version = "3.0.0"
3
+ version = "4.0.2"
4
4
  description = "The HTTP edge of xtr security: firewalls, authenticators, bearer access tokens and the request surface."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -20,11 +20,13 @@ classifiers = [
20
20
  "Typing :: Typed",
21
21
  ]
22
22
  dependencies = [
23
- "xtr-security-core>=3.0,<4",
24
- "xtr-http-kernel>=3.0,<4",
25
- "xtr-event-dispatcher-contracts>=3.0,<4",
26
- "xtr-logging-contracts>=3.0,<4",
27
- "xtr-password-hasher>=3.0,<4",
23
+ "xtr-security-core>=4.0,<5",
24
+ "xtr-http-kernel>=4.0,<5",
25
+ "xtr-dependency-injection[fastapi]>=4.0,<5",
26
+ "xtr-event-dispatcher>=4.0,<5",
27
+ "xtr-event-dispatcher-contracts>=4.0,<5",
28
+ "xtr-logging-contracts>=4.0,<5",
29
+ "xtr-password-hasher>=4.0,<5",
28
30
  "fastapi>=0.123",
29
31
  "starlette>=0.40",
30
32
  "anyio>=4.0",
@@ -45,8 +47,6 @@ dev = [
45
47
  "pytest-cov>=5",
46
48
  "anyio>=4.0",
47
49
  "ty>=0.0.83",
48
- "xtr-dependency-injection>=3.0,<4",
49
- "xtr-event-dispatcher>=3.0,<4",
50
50
  "httpx>=0.27",
51
51
  "joserfc>=1.7",
52
52
  ]
@@ -9,6 +9,8 @@ and xtr-http-kernel.
9
9
 
10
10
  from __future__ import annotations
11
11
 
12
+ from importlib.metadata import PackageNotFoundError, version
13
+
12
14
  from .access_map import AccessMap
13
15
  from .access_map_interface import AccessMapInterface
14
16
  from .access_token import (
@@ -40,6 +42,7 @@ from .authenticator.passport.badge import (
40
42
  PreAuthenticatedUserBadge,
41
43
  UserBadge,
42
44
  )
45
+ from .authenticator.passport.badge.user_badge import MAX_USERNAME_LENGTH
43
46
  from .authenticator.passport.credentials import (
44
47
  CredentialsInterface,
45
48
  CustomCredentials,
@@ -51,6 +54,7 @@ from .authorization import (
51
54
  InsufficientScopeAccessDeniedHandler,
52
55
  OAuth2ScopeVoter,
53
56
  oauth2_scope,
57
+ parse_oauth2_scope,
54
58
  )
55
59
  from .decorator.current_user import CurrentUser
56
60
  from .decorator.is_granted import IsGranted
@@ -82,8 +86,18 @@ from .firewall_scheme_registry import (
82
86
  FirewallSchemeRegistry,
83
87
  active_firewall_schemes,
84
88
  )
89
+ from .request_matcher import (
90
+ CallableRequestMatcher,
91
+ ChainRequestMatcher,
92
+ HostRequestMatcher,
93
+ IpRequestMatcher,
94
+ MethodRequestMatcher,
95
+ PathRequestMatcher,
96
+ RequestMatcherInterface,
97
+ )
85
98
 
86
99
  __all__ = [
100
+ "MAX_USERNAME_LENGTH",
87
101
  "AbstractAuthenticator",
88
102
  "AccessDeniedHandlerInterface",
89
103
  "AccessListener",
@@ -100,7 +114,9 @@ __all__ = [
100
114
  "AuthenticatorManager",
101
115
  "AuthenticatorManagerInterface",
102
116
  "BadgeInterface",
117
+ "CallableRequestMatcher",
103
118
  "ChainAccessTokenExtractor",
119
+ "ChainRequestMatcher",
104
120
  "CheckCredentialsListener",
105
121
  "CheckPassportEvent",
106
122
  "CredentialsInterface",
@@ -117,27 +133,41 @@ __all__ = [
117
133
  "FirewallSchemeRegistry",
118
134
  "FormEncodedBodyExtractor",
119
135
  "HeaderAccessTokenExtractor",
136
+ "HostRequestMatcher",
120
137
  "InsufficientScopeAccessDeniedHandler",
121
138
  "InvalidAccessTokenError",
139
+ "IpRequestMatcher",
122
140
  "IsGranted",
123
141
  "IsGrantedContext",
124
142
  "LoginFailureEvent",
125
143
  "LoginSuccessEvent",
144
+ "MethodRequestMatcher",
126
145
  "OAuth2ScopeVoter",
127
146
  "Passport",
128
147
  "PasswordCredentials",
129
148
  "PasswordMigratingListener",
130
149
  "PasswordUpgradeBadge",
150
+ "PathRequestMatcher",
131
151
  "PostAuthenticationToken",
132
152
  "PreAuthenticatedUserBadge",
133
153
  "QueryAccessTokenExtractor",
154
+ "RequestMatcherInterface",
134
155
  "SelfValidatingPassport",
135
156
  "UnknownFirewallError",
136
157
  "UserBadge",
137
158
  "UserCheckerListener",
138
159
  "UserProviderListener",
160
+ "__version__",
139
161
  "active_firewall_schemes",
140
162
  "is_sensitive",
141
163
  "mask",
142
164
  "oauth2_scope",
165
+ "parse_oauth2_scope",
143
166
  ]
167
+
168
+ try:
169
+ __version__ = version("xtr-security-http")
170
+ except PackageNotFoundError: # pragma: no cover
171
+ # Running from a source tree with no installed metadata to read; having no
172
+ # version is better than refusing to import.
173
+ __version__ = "0+unknown"
@@ -0,0 +1,64 @@
1
+ r"""Writing a ``WWW-Authenticate`` challenge: quoting and cache headers.
2
+
3
+ A challenge's ``realm``, ``error_description`` and ``scope`` are written as
4
+ RFC 7235 ``quoted-string`` values; a ``\`` or a ``"`` inside one must be
5
+ backslash-escaped or the header is malformed. Both the access-token
6
+ authenticator's challenge and the insufficient-scope handler's challenge quote
7
+ their values through here, so the escaping is written once.
8
+
9
+ Every challenge response also carries the no-store cache directives
10
+ :func:`no_store_headers` adds, so a ``401`` or ``403`` is never kept by a shared
11
+ cache or a browser and replayed.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import re
17
+ from typing import Final
18
+
19
+ from xtr_security_core.exception import InvalidArgumentError
20
+
21
+ __all__ = ["no_store_headers", "quote_auth_param"]
22
+
23
+ #: The control characters a ``quoted-string`` cannot carry. Its ``qdtext``
24
+ #: admits HTAB and SP; every other character below ``%x20``, and ``%x7f``, has
25
+ #: no spelling in the grammar — not even as a ``quoted-pair``.
26
+ _REFUSED_CONTROLS: Final = re.compile(r"[\x00-\x08\x0a-\x1f\x7f]")
27
+
28
+ #: The cache directives a challenge response carries, so neither a shared cache
29
+ #: nor a browser keeps a ``401``/``403`` and replays it to another caller or
30
+ #: after the credential changed. ``no-store`` is the HTTP/1.1 directive;
31
+ #: ``Pragma: no-cache`` is the HTTP/1.0 belt-and-braces an old intermediary
32
+ #: still reads.
33
+ _NO_STORE: Final = {"Cache-Control": "no-store", "Pragma": "no-cache"}
34
+
35
+
36
+ def quote_auth_param(value: str) -> str:
37
+ r"""Return ``value`` as an RFC 7235 ``quoted-string``, escaping ``\`` and ``"``.
38
+
39
+ Raises:
40
+ InvalidArgumentError: When ``value`` carries a control character. A
41
+ ``quoted-string`` has no way to write one, so the challenge would be
42
+ malformed — and a CR or an LF would split the header. Every value
43
+ quoted here is declared by the application, so this is a mistake to
44
+ report where it is made, not a request to answer.
45
+ """
46
+ refused = _REFUSED_CONTROLS.search(value)
47
+ if refused is not None:
48
+ raise InvalidArgumentError(
49
+ f"An auth-param value cannot carry the control character {refused.group()!r}.",
50
+ )
51
+ escaped = value.replace("\\", "\\\\").replace('"', '\\"')
52
+ return f'"{escaped}"'
53
+
54
+
55
+ def no_store_headers(**headers: str) -> dict[str, str]:
56
+ """Return ``headers`` with the no-store cache directives a challenge needs.
57
+
58
+ A challenge response must not be cached: a shared cache that kept a ``401``
59
+ would answer another caller's request with it, and a browser that kept one
60
+ would replay it after the credential changed. Every challenge this package
61
+ writes merges its own headers — a ``WWW-Authenticate`` — through here so the
62
+ directives are set in one place.
63
+ """
64
+ return {**_NO_STORE, **headers}
@@ -16,7 +16,7 @@ from typing import TYPE_CHECKING
16
16
 
17
17
  from xtr_security_core.authentication.token.null_token import NullToken
18
18
  from xtr_security_core.authorization.access_decision import AccessDecision
19
- from xtr_security_core.exception import AccessDeniedError, AuthenticationError
19
+ from xtr_security_core.exception import AccessDeniedError
20
20
 
21
21
  from ._state import (
22
22
  AUTHENTICATED_FIREWALLS_KEY,
@@ -63,6 +63,10 @@ async def run_firewall( # noqa: PLR0913 -- the firewall's request, scopes and i
63
63
  """
64
64
  context = _resolve(firewall_map, name, request)
65
65
  if context is None or not context.security:
66
+ if scopes:
67
+ raise AccessDeniedError(
68
+ "A scoped resource was reached with no firewall to authenticate the caller.",
69
+ )
66
70
  return
67
71
 
68
72
  setattr(request.state, FIREWALL_CONTEXT_KEY, context)
@@ -86,25 +90,38 @@ def _resolve(
86
90
  async def _authenticate_once(context: FirewallContextInterface, request: Request) -> None:
87
91
  """Authenticate the request under ``context`` at most once, memoised by name.
88
92
 
93
+ Only a settled outcome is memoised: ``None`` for a pass that authenticated,
94
+ the exception for one that did not. Recording a crash as a pass would let a
95
+ later call over the same request skip authentication and go on with a null
96
+ token, so whatever the authenticators raised — a failure the handlers own or
97
+ a bug they do not — is recorded and raised again by every later call. A later
98
+ call raises a fresh instance of the recorded error's type and message rather
99
+ than the stored instance itself, so re-raising it does not splice this
100
+ request's traceback onto the one the first call already unwound, nor let a
101
+ caller that mutates the caught exception change what a later call raises.
102
+
89
103
  Raises:
90
104
  AuthenticationError: When authentication fails, on this call and every
91
105
  later one this request.
106
+ BaseException: Whatever else authentication raised, re-raised by every
107
+ later call this request rather than passing it off as authenticated.
92
108
  CarriedResponse: When a handler produced a response to send.
93
109
  """
94
110
  memo: dict[str, BaseException | None] = getattr(request.state, AUTHENTICATED_FIREWALLS_KEY, {})
95
111
  if not memo:
96
112
  setattr(request.state, AUTHENTICATED_FIREWALLS_KEY, memo)
97
113
  if context.name in memo:
98
- outcome = memo[context.name]
99
- if isinstance(outcome, BaseException):
100
- raise outcome
114
+ recorded = memo[context.name]
115
+ if recorded is not None:
116
+ raise type(recorded)(str(recorded))
101
117
  return
102
118
  try:
103
119
  response = await context.authenticator_manager.authenticate_request(request)
104
- except AuthenticationError as error:
120
+ except BaseException as error:
105
121
  memo[context.name] = error
106
122
  raise
107
- memo[context.name] = None
123
+ else:
124
+ memo[context.name] = None
108
125
  if response is not None:
109
126
  raise CarriedResponse(response)
110
127
 
@@ -22,9 +22,10 @@ __all__ = [
22
22
 
23
23
  _PREFIX: Final = "_xtr_security_"
24
24
 
25
- #: Maps a firewall name to the outcome of authenticating this request under it —
26
- #: the error authentication raised, or ``None`` for a plain success — so
27
- #: authentication stays one pass per firewall.
25
+ #: Maps a firewall name to the settled outcome of authenticating this request
26
+ #: under it — the exception authentication raised, or ``None`` for a pass that
27
+ #: authenticated — so authentication stays one pass per firewall. A name is
28
+ #: absent until an outcome settles, so no unfinished pass reads as authenticated.
28
29
  AUTHENTICATED_FIREWALLS_KEY: Final = f"{_PREFIX}authenticated_firewalls"
29
30
 
30
31
  #: The firewall context in charge of the request, stashed by the firewall so the
@@ -0,0 +1,120 @@
1
+ """An extractor that reads a bearer token from a form-encoded body."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, final
6
+ from urllib.parse import parse_qs
7
+
8
+ from fastapi.security import HTTPBearer
9
+ from typing_extensions import override
10
+
11
+ from .access_token_extractor_interface import AccessTokenExtractorInterface
12
+
13
+ if TYPE_CHECKING:
14
+ from fastapi.security.base import SecurityBase
15
+ from starlette.requests import Request
16
+
17
+ __all__ = ["FormEncodedBodyExtractor"]
18
+
19
+ _FORM_CONTENT_TYPE = "application/x-www-form-urlencoded"
20
+
21
+ #: The largest form body read, so a bearer token in a body cannot be a memory
22
+ #: exhaustion vector; a larger declared or streamed body yields no token.
23
+ _MAX_BODY_SIZE = 64 * 1024
24
+
25
+
26
+ @final
27
+ class FormEncodedBodyExtractor(AccessTokenExtractorInterface):
28
+ """Reads a token from a form field of an ``x-www-form-urlencoded`` body.
29
+
30
+ RFC 6750 allows a bearer token in a form body; this reads the
31
+ ``access_token`` field of one, when the request carries that content type.
32
+ The body is parsed directly, so no form-parsing dependency is needed. There
33
+ is no dedicated OpenAPI scheme for a body-carried token, so it documents
34
+ itself as the standard bearer scheme.
35
+
36
+ Attributes:
37
+ field_name: The form field the token is read from.
38
+ """
39
+
40
+ __slots__ = ("_bearer", "_field_name")
41
+
42
+ def __init__(self, field_name: str = "access_token") -> None:
43
+ """Build the extractor for the ``field_name`` form field."""
44
+ self._field_name = field_name
45
+ self._bearer = HTTPBearer(auto_error=False, bearerFormat="JWT", scheme_name="Bearer")
46
+
47
+ @override
48
+ async def extract_access_token(self, request: Request) -> str | None:
49
+ """Return the token in the form body, or ``None`` when it is absent.
50
+
51
+ A body whose ``content-length`` is above the 64 KiB cap — or, with no
52
+ usable ``content-length``, one that streams past the cap — yields
53
+ ``None``, as does a body that is not valid UTF-8; an endpoint behind the
54
+ firewall then challenges rather than failing on the oversize or
55
+ undecodable body.
56
+ """
57
+ content_type = request.headers.get("content-type", "")
58
+ if not content_type.lower().startswith(_FORM_CONTENT_TYPE):
59
+ return None
60
+ body = await _read_capped(request)
61
+ if body is None:
62
+ return None
63
+ try:
64
+ text = body.decode("utf-8", errors="strict")
65
+ except UnicodeDecodeError:
66
+ return None
67
+ fields = parse_qs(text)
68
+ values = fields.get(self._field_name)
69
+ return values[0] if values else None
70
+
71
+ @override
72
+ def scheme(self) -> SecurityBase:
73
+ """Return the bearer scheme this extractor documents by."""
74
+ return self._bearer
75
+
76
+
77
+ async def _read_capped(request: Request) -> bytes | None:
78
+ """Read the body of ``request`` up to the cap, or ``None`` when it is exceeded.
79
+
80
+ A ``content-length`` the sender declares is only believed when it is a run
81
+ of ASCII digits, which is all RFC 9110 allows: ``int`` would also take
82
+ ``-1``, ``+5`` or a padded ``" 10 "``, and reading the body on the strength
83
+ of such a header would leave the cap unenforced. A header is decoded as
84
+ latin-1, so it can even carry a superscript ``²``, which counts as a digit
85
+ yet is no number ``int`` can read. Anything else — absent, padded, signed or
86
+ not a number — is read by streaming, which caps on what actually arrives
87
+ rather than on what was promised. A body read by streaming is cached back on
88
+ the request through :func:`_cache_body`, so an endpoint behind the firewall
89
+ can still read its own body — ``await request.body()`` or a form parse —
90
+ after the extractor has looked for a token.
91
+ """
92
+ declared = request.headers.get("content-length")
93
+ if declared is not None and declared.isascii() and declared.isdigit():
94
+ if int(declared) > _MAX_BODY_SIZE:
95
+ return None
96
+ return await request.body()
97
+ collected = bytearray()
98
+ async for chunk in request.stream():
99
+ collected.extend(chunk)
100
+ if len(collected) > _MAX_BODY_SIZE:
101
+ return None
102
+ body = bytes(collected)
103
+ _cache_body(request, body)
104
+ return body
105
+
106
+
107
+ def _cache_body(request: Request, body: bytes) -> None:
108
+ """Hand the streamed ``body`` back to Starlette as the request's cached body.
109
+
110
+ ``request.stream()`` consumes the body once; without this, a later
111
+ ``await request.body()`` from the endpoint raises ``RuntimeError: Stream
112
+ consumed``. Starlette's own ``body()`` caches into the ``_body`` attribute
113
+ and every later ``stream()``/``body()`` serves from it, so setting that
114
+ attribute is how a value read ahead of the route is made available to it.
115
+ This is the one private attribute that mechanism exposes; it is set in this
116
+ single documented place.
117
+ """
118
+ # SLF001/reportPrivateUsage: Starlette's own body() caches into this same
119
+ # attribute; setting it is the supported re-read path. See the docstring.
120
+ request._body = body # noqa: SLF001 # pyright: ignore[reportPrivateUsage]
@@ -50,7 +50,8 @@ class OidcTokenHandler:
50
50
 
51
51
  The identifier is read from ``claim`` (``sub`` by default). With no provider
52
52
  the claims are the user — an
53
- :class:`~xtr_security_core.user.oidc_user.OidcUser`; with one, its
53
+ :class:`~xtr_security_core.user.oidc_user.OidcUser` built from every claim,
54
+ so a ``roles`` claim grants the roles it holds; with one, its
54
55
  identifier loads the user, the claims handed alongside to a provider that
55
56
  reads them. The badge carries the granted ``scope`` (from the ``scope``
56
57
  string or the ``scp`` list), the ``client_id``, the ``jti`` and every claim.
@@ -158,8 +159,8 @@ class OidcTokenHandler:
158
159
  "iss": {"essential": True, "values": list(self._issuers)},
159
160
  "aud": {"essential": True, "value": self._audience},
160
161
  "exp": {"essential": True},
161
- self._claim: {"essential": True},
162
162
  }
163
+ options[self._claim] = {**options.get(self._claim, {}), "essential": True}
163
164
  registry = JWTClaimsRegistry(now=self._clock(), leeway=self._leeway, **options)
164
165
  try:
165
166
  registry.validate(dict(claims))
@@ -192,12 +193,11 @@ class OidcTokenHandler:
192
193
  the claims travel to it; with a plain one only the identifier does.
193
194
  """
194
195
  provider = self._user_provider
195
- claim = self._claim
196
196
  if provider is None:
197
197
 
198
198
  def load_from_claims(identifier: str) -> UserInterface:
199
199
  del identifier
200
- return OidcUser(claims, identifier_claim=claim)
200
+ return OidcUser.from_claims(claims)
201
201
 
202
202
  return load_from_claims
203
203
  if AttributesBasedUserProviderInterface in type(provider).__mro__:
@@ -8,6 +8,7 @@ from starlette.responses import JSONResponse
8
8
  from typing_extensions import override
9
9
  from xtr_security_core.exception import AuthenticationError, BadCredentialsError
10
10
 
11
+ from xtr_security_http._challenge import no_store_headers, quote_auth_param
11
12
  from xtr_security_http.authenticator.passport.self_validating_passport import SelfValidatingPassport
12
13
  from xtr_security_http.authorization.oauth2_scope_voter import OAuth2ScopeVoter
13
14
  from xtr_security_http.entry_point.authentication_entry_point_interface import (
@@ -95,6 +96,11 @@ class AccessTokenAuthenticator(AbstractAuthenticator, AuthenticationEntryPointIn
95
96
  self._failure_handler = failure_handler
96
97
  self._realm = realm
97
98
 
99
+ @property
100
+ def extractor(self) -> AccessTokenExtractorInterface:
101
+ """Return the extractor that reads the token out of a request."""
102
+ return self._extractor
103
+
98
104
  @override
99
105
  def supports(self, request: Request) -> bool | None:
100
106
  """Handle the request only when it carries a token this extractor reads.
@@ -180,14 +186,18 @@ class AccessTokenAuthenticator(AbstractAuthenticator, AuthenticationEntryPointIn
180
186
  del request
181
187
  parts = ["Bearer"]
182
188
  if self._realm is not None:
183
- parts.append(f'realm="{self._realm}"')
189
+ parts.append(f"realm={quote_auth_param(self._realm)}")
184
190
  body: dict[str, str] = {}
185
191
  if isinstance(error, InvalidAccessTokenError):
186
192
  parts.append('error="invalid_token"')
187
- parts.append(f'error_description="{error.get_message_key()}"')
193
+ parts.append(f"error_description={quote_auth_param(error.get_message_key())}")
188
194
  body = {"error": "invalid_token"}
189
195
  challenge = parts[0] + (" " + ", ".join(parts[1:]) if len(parts) > 1 else "")
190
- return JSONResponse(body, status_code=401, headers={"WWW-Authenticate": challenge})
196
+ return JSONResponse(
197
+ body,
198
+ status_code=401,
199
+ headers=no_store_headers(**{"WWW-Authenticate": challenge}),
200
+ )
191
201
 
192
202
  def _normalize_scope(self, scope: object) -> list[str]:
193
203
  """Turn a scope list or space-separated string into a list of scopes."""
@@ -50,7 +50,12 @@ class Passport:
50
50
  self._attributes: dict[str, object] = dict(attributes) if attributes is not None else {}
51
51
 
52
52
  def add_badge(self, badge: BadgeInterface) -> Passport:
53
- """Attach ``badge``, replacing any badge of its class, and return self."""
53
+ """Attach ``badge``, replacing any badge of its exact class, and return self.
54
+
55
+ Badges are keyed by ``type(badge)`` — the exact runtime class, not a base
56
+ class — so a subclass of a badge does not replace, and is not found by,
57
+ the base class it derives from.
58
+ """
54
59
  self._badges[type(badge)] = badge
55
60
  return self
56
61
 
@@ -59,7 +64,11 @@ class Passport:
59
64
  return badge_class in self._badges
60
65
 
61
66
  def get_badge(self, badge_class: type[_BadgeT]) -> _BadgeT | None:
62
- """Return the badge of ``badge_class``, or ``None`` when none is attached."""
67
+ """Return the badge of ``badge_class``, or ``None`` when none is attached.
68
+
69
+ The lookup is by exact class: a badge is found only under the class it was
70
+ attached as, never under a base class of it.
71
+ """
63
72
  badge = self._badges.get(badge_class)
64
73
  return badge if isinstance(badge, badge_class) else None
65
74