$$$` | `n=2**14`, `r=8`, `p=1`, `salt_bytes=16`, `dklen=32` |
+
+```python
+from pyfly.security import Argon2PasswordEncoder, Pbkdf2PasswordEncoder, ScryptPasswordEncoder
+
+argon2 = Argon2PasswordEncoder() # OWASP-preferred; Argon2id
+pbkdf2 = Pbkdf2PasswordEncoder() # FIPS-friendly; 600k SHA-256 iterations
+scrypt = ScryptPasswordEncoder() # memory-hard
+```
+
+`Argon2PasswordEncoder` imports `argon2-cffi` lazily, so the rest of the security module works without it; install with `pip install pyfly[argon2]`. Calling `hash()`/`verify()` without the dependency raises `ImportError`.
+
+#### Opt-in delegating bean
+
+The auto-configuration always exposes a `BcryptPasswordEncoder` bean when `pyfly.security.enabled=true` and bcrypt is installed. Setting `pyfly.security.password.delegating.enabled=true` additionally registers a `DelegatingPasswordEncoder` bean built via `create_delegating_password_encoder()`, reusing `pyfly.security.password.bcrypt-rounds` for the default encoder:
+
+```yaml
+pyfly:
+ security:
+ enabled: true
+ password:
+ bcrypt-rounds: 12
+ delegating:
+ enabled: true # registers the {id}-prefixed DelegatingPasswordEncoder bean
+```
+
+#### SqlUserDetailsService note
+
+`SqlUserDetailsService` (`pyfly.security.adapters.sql_user_details`) stores `password_hash` verbatim in a `TEXT` column, so `{id}`-prefixed delegating hashes round-trip unchanged. This makes on-login migration straightforward: after a successful `verify()`, call `upgrade_encoding()` on the stored hash and, when it returns `True`, re-hash with the delegating encoder and persist via `SqlUserDetailsService.save(...)`.
+
---
## SecurityMiddleware
@@ -706,6 +803,28 @@ fetch('/api/orders', {
**Source:** `src/pyfly/web/adapters/starlette/filters/csrf_filter.py`
+### Enabled by Default (cookie-gated)
+
+CSRF protection is **secure-by-default**: `CsrfFilterAutoConfiguration` registers the `CsrfFilter` unless `pyfly.security.csrf.enabled=false` (the property is treated as enabled when missing). The filter runs in **cookie-gated** mode by default, which lets it be on without breaking stateless/token clients:
+
+- **Safe methods** (GET, HEAD, OPTIONS, TRACE) pass through and the response sets/refreshes the `XSRF-TOKEN` cookie.
+- **Bearer requests** (`Authorization: Bearer ...`) are exempt — JWT API clients carry no ambient browser authority to forge.
+- **Cookie-gated exemption** — when `cookie-gated` is true and the request carries no cookies, there is no ambient authority a cross-site request could abuse, so it is exempt. This is what makes default-on safe for stateless API clients.
+- **Unsafe methods** with cookies are validated by comparing the `X-XSRF-TOKEN` header against the `XSRF-TOKEN` cookie (timing-safe); a missing or mismatched value returns HTTP 403.
+
+Set `cookie-gated: false` for **strict mode**, which validates every unsafe request regardless of cookies. Disable CSRF entirely with `enabled: false`. The filter's exclude patterns default to `/actuator/*`, `/health`, `/ready` and can be overridden:
+
+```yaml
+pyfly:
+ security:
+ csrf:
+ enabled: true # default; set false to disable entirely
+ cookie-gated: true # default; false = strict (validate every unsafe request)
+ exclude-patterns: "/actuator/*,/webhooks/**"
+```
+
+**Source:** `src/pyfly/web/security_filters_auto_configuration.py`, `src/pyfly/web/adapters/starlette/filters/csrf_filter.py`
+
---
## HttpSecurity DSL
@@ -809,6 +928,32 @@ The filter is automatically included in the WebFilter chain and sorted by its `@
**Source:** `src/pyfly/security/http_security.py`, `src/pyfly/web/adapters/starlette/filters/http_security_filter.py`
+#### HTTP-Method-Scoped Rules
+
+`request_matchers(...)` accepts an optional `methods` argument to scope a rule to specific HTTP verbs, mirroring Spring's `requestMatchers(HttpMethod.X, ...)`. Pass a single method as a string or several as a list/tuple; values are upper-cased. When `methods` is omitted the rule matches any method.
+
+```python
+from pyfly.security.http_security import HttpSecurity
+
+http_security = HttpSecurity()
+http_security.authorize_requests() \
+ .request_matchers("/api/orders/**", methods="GET").authenticated() \
+ .request_matchers("/api/orders/**", methods="POST").has_role("ADMIN") \
+ .request_matchers("/api/orders/**", methods=["PUT", "DELETE"]).has_role("ADMIN") \
+ .any_request().permit_all()
+
+http_security_filter = http_security.build()
+```
+
+`any_request()` takes the same keyword to restrict the catch-all to specific methods:
+
+```python
+http_security.authorize_requests() \
+ .any_request(methods=["PUT", "PATCH", "DELETE"]).authenticated()
+```
+
+A rule with an empty method list (the default) applies to every method; otherwise it applies only when the request method is one of the listed (upper-cased) verbs.
+
---
### Method-Level Security
@@ -930,509 +1075,533 @@ async def list_orders(self) -> list[Order]: ...
---
-## OAuth2
+#### @pre_filter / @post_filter and PermissionEvaluator
-PyFly provides a complete OAuth2 implementation following hexagonal architecture. The module includes a Resource Server for validating external tokens, Client Registration for connecting to OAuth2 providers, and an Authorization Server for issuing tokens.
+`@pre_filter` and `@post_filter` filter *collections* element-by-element against a security expression, binding each element to `filterObject` (Spring's `@PreFilter` / `@PostFilter`). They complement the all-or-nothing `@pre_authorize` / `@post_authorize` checks.
+
+`@post_filter(expression)` filters the method's returned collection after it runs; non-collection results are returned unchanged. `@pre_filter(expression, filter_target=None)` filters a collection *argument* before the method runs — `filter_target` names the parameter to filter; when omitted, the first collection-valued argument is used. Both preserve the collection's concrete type (`list` / `tuple` / `set`) and drop elements for which the expression is `False`.
```python
-from pyfly.security.oauth2 import (
- # Resource Server
- JWKSTokenValidator,
- # Client Registration
- ClientRegistration,
- ClientRegistrationRepository,
- InMemoryClientRegistrationRepository,
- google,
- github,
- keycloak,
- # Authorization Server
- AuthorizationServer,
- TokenStore,
- InMemoryTokenStore,
-)
-```
+from pyfly.security import pre_filter, post_filter
-### OAuth2 Resource Server (JWKS)
-The `JWKSTokenValidator` validates JWTs against a remote JWKS (JSON Web Key Set) endpoint. Use it when your application is an **OAuth2 Resource Server** — it receives bearer tokens issued by an external authorization server and validates the signature, `iss`, `aud` and `exp` (with clock-skew leeway). It is **multi-IdP out of the box**: Keycloak, Microsoft Entra ID (v1.0 + v2.0) and AWS Cognito all work via configuration, no subclassing.
+@service
+class DocumentService:
+
+ # Return only the documents the caller owns.
+ @post_filter("filterObject.owner_id == principal.user_id")
+ async def list_documents(self) -> list[Document]:
+ return await self._repo.find_all()
-#### Enable via configuration (recommended)
+ # Keep only non-draft documents from the incoming batch before publishing.
+ @pre_filter("filterObject.draft == False", filter_target="documents")
+ async def publish(self, documents: list[Document]) -> None:
+ ...
+```
-The resource-server filter auto-wires when `pyfly.security.oauth2.resource-server.enabled=true`. It binds [`ResourceServerProperties`](#) and adds a bearer-token filter to the chain.
+##### PermissionEvaluator (ACL-style hasPermission)
-```yaml
-pyfly:
- security:
- enabled: true
- oauth2:
- resource-server:
- enabled: true
- # Provide a JWKS URI directly, OR an issuer-uri for OIDC discovery:
- issuer-uri: "https://login.microsoftonline.com//v2.0" # discovers jwks-uri + issuer
- # jwks-uri: "https://login.microsoftonline.com//discovery/v2.0/keys"
- audiences: "api://my-backend" # comma-separated; token aud must match ANY
- validate-audience: true # set false for Cognito ACCESS tokens (they carry no aud)
- algorithms: "RS256"
- clock-skew-seconds: 60 # leeway for iat/nbf/exp (default 60)
- # Config-driven claim mapping (dotted paths, '*' wildcard, colon-safe):
- principal-claim-names: "oid,sub"
- authorities-claim-names: "roles,realm_access.roles,resource_access.*.roles,groups,cognito:groups"
- scope-claim-names: "scp,scope" # Entra uses scp; Keycloak/Cognito use scope
- attribute-claims: "tid,preferred_username"
- authority-prefix: "" # e.g. "ROLE_" / "SCOPE_" for Spring-style authorities
- exclude-patterns: "/actuator/**,/api/v1/version"
- authenticate-error-mode: "anonymous" # or "401" to reject invalid tokens at the filter
-```
-
-Per-IdP quick reference:
-
-| IdP | `issuer` | Roles claim(s) | Scopes | Audience |
-|---|---|---|---|---|
-| **Keycloak** | `https:///realms/` | `realm_access.roles`, `resource_access.*.roles` | `scope` | client / `account` |
-| **Entra ID v2.0** | `https://login.microsoftonline.com//v2.0` | `roles`, `groups` | `scp` | `api://…` or client GUID |
-| **Cognito (access)** | `https://cognito-idp..amazonaws.com/` | `cognito:groups` | `scope` | **none** → set `validate-audience: false` |
-
-#### Programmatic use
+`PermissionEvaluator` is the SPI behind domain-object `hasPermission(...)` checks. It is a runtime-checkable `Protocol` with a single method:
```python
-from pyfly.security.oauth2 import JWKSTokenValidator, ClaimMappings
-
-validator = JWKSTokenValidator(
- jwks_uri="https://auth.example.com/.well-known/jwks.json",
- issuer="https://auth.example.com",
- audiences=["my-api"],
- leeway=60,
- claim_mappings=ClaimMappings(attribute_claims=("tid",)),
-)
-ctx = validator.to_security_context(token)
-# SecurityContext(user_id=..., roles=[...], permissions=[...], attributes={...})
+def has_permission(
+ self,
+ context: Any, # the active SecurityContext
+ target: Any, # the domain object, or its identifier (3-arg form)
+ permission: str,
+ *,
+ target_type: str | None = None,
+) -> bool: ...
```
-**Constructor parameters:**
+Install one process-wide with `set_permission_evaluator()`; `get_permission_evaluator()` returns the current one and `set_permission_evaluator(None)` disables it. When an evaluator is installed, the `hasPermission` function in security expressions dispatches to it by argument shape:
-| Parameter | Type | Default | Description |
-|---|---|---|---|
-| `jwks_uri` | `str` | required | URL of the JWKS endpoint |
-| `issuer` | `str \| None` | `None` | Expected `iss` claim (validated if set) |
-| `audiences` | `list[str] \| None` | `None` | Accepted audiences; `aud` must match any. Empty disables `aud` validation |
-| `algorithms` | `list[str] \| None` | `["RS256"]` | Allowed signing algorithms |
-| `leeway` | `int` | `60` | Clock-skew tolerance (seconds) for `iat`/`nbf`/`exp` |
-| `validate_audience` | `bool` | `True` | Skip `aud` validation when `False` (Cognito access tokens) |
-| `claim_mappings` | `ClaimMappings \| None` | multi-IdP defaults | Config-driven claim→context mapping |
+- `hasPermission('perm')` — flat check: `has_permission(ctx, None, 'perm')`
+- `hasPermission(target, 'perm')` — domain object: `has_permission(ctx, target, 'perm')`
+- `hasPermission(id, 'Type', 'perm')` — identifier + type: `has_permission(ctx, id, 'perm', target_type='Type')`
-**Claim mapping (`ClaimMappings`):** claim names are searched as **dotted paths** with a single `*` wildcard (`resource_access.*.roles`) and are colon-safe (`cognito:groups`). Defaults map authorities from `roles`, `realm_access.roles`, `resource_access.*.roles`, `groups`, `cognito:groups`; scopes from `scp`, `scope`; principal from `oid` then `sub`.
+When **no** evaluator is installed, `hasPermission` falls back to a flat permission check on the `SecurityContext` (the principal's granted permissions), using the last argument as the permission name.
-To customise per IdP without subclassing, set the `*-claim-names` config keys. An application that needs bespoke mapping can still subclass `JWKSTokenValidator` and register it — `@conditional_on_missing_bean(JWKSTokenValidator)` backs the default off.
+```python
+from pyfly.security import PermissionEvaluator, set_permission_evaluator
-**OIDC discovery:** set `issuer-uri` (instead of `jwks-uri`) and the framework fetches `/.well-known/openid-configuration` to learn the `jwks_uri` + `issuer`.
-**Source:** `src/pyfly/security/oauth2/resource_server.py`, `src/pyfly/security/oauth2/properties.py`
+class AclPermissionEvaluator:
+ def has_permission(self, context, target, permission, *, target_type=None) -> bool:
+ # Consult your ACL store using context.user_id, target/target_type, permission.
+ ...
-### OAuth2 Client Registration
-`ClientRegistration` is a frozen dataclass that holds the configuration needed to interact with an OAuth2 provider.
+set_permission_evaluator(AclPermissionEvaluator())
+```
```python
-from pyfly.security.oauth2 import ClientRegistration
-
-registration = ClientRegistration(
- registration_id="my-app",
- client_id="client-id-from-provider",
- client_secret="client-secret-from-provider",
- authorization_grant_type="authorization_code",
- redirect_uri="https://myapp.com/callback",
- scopes=["openid", "profile", "email"],
- authorization_uri="https://provider.com/authorize",
- token_uri="https://provider.com/token",
- user_info_uri="https://provider.com/userinfo",
- jwks_uri="https://provider.com/.well-known/jwks.json",
- issuer_uri="https://provider.com",
- provider_name="Custom Provider",
-)
+@service
+class OrderService:
+
+ @pre_authorize("hasPermission(#order, 'order:write')")
+ async def update(self, order: Order) -> None: ...
+
+ @pre_authorize("hasPermission(#order_id, 'Order', 'write')")
+ async def update_by_id(self, order_id: str) -> None: ...
```
-**Fields:**
+**Source:** `src/pyfly/security/method_security.py`, `src/pyfly/security/expression.py`, `src/pyfly/security/permission.py`
-| Field | Type | Default | Description |
-|---|---|---|---|
-| `registration_id` | `str` | required | Unique identifier for this registration |
-| `client_id` | `str` | required | OAuth2 client ID |
-| `client_secret` | `str` | `""` | OAuth2 client secret |
-| `authorization_grant_type` | `str` | `"authorization_code"` | Grant type |
-| `redirect_uri` | `str` | `""` | Redirect URI for auth code flow |
-| `scopes` | `list[str]` | `[]` | Requested scopes |
-| `authorization_uri` | `str` | `""` | Provider's authorization endpoint |
-| `token_uri` | `str` | `""` | Provider's token endpoint |
-| `user_info_uri` | `str` | `""` | Provider's userinfo endpoint |
-| `jwks_uri` | `str` | `""` | Provider's JWKS endpoint |
-| `issuer_uri` | `str` | `""` | Provider's issuer URI |
-| `provider_name` | `str` | `""` | Human-readable provider name |
-| `use_pkce` | `bool` | `False` | Enable PKCE (RFC 7636, S256) on the `authorization_code` flow |
-
-##### PKCE (Proof Key for Code Exchange)
-
-Setting `use_pkce=True` enables PKCE (RFC 7636) on the `authorization_code` login flow. Recommended for public clients (no `client_secret`), and harmless — more secure — for confidential clients too.
+---
-```python
-from pyfly.security.oauth2 import ClientRegistration
-
-registration = ClientRegistration(
- registration_id="my-app",
- client_id="public-client-id",
- authorization_grant_type="authorization_code",
- redirect_uri="https://myapp.com/login/oauth2/code/my-app",
- authorization_uri="https://provider.com/authorize",
- token_uri="https://provider.com/token",
- use_pkce=True,
-)
-```
+## Authentication Mechanisms
+
+Beyond stateless JWT processing, PyFly ships the Spring Security authentication SPI: a `UserDetailsService` that resolves a username to a stored credential, an `AuthenticationManager` (`ProviderManager`) that delegates to one or more `AuthenticationProvider`s, and a family of `WebFilter`s that establish a `SecurityContext` from HTTP Basic credentials, a login form, a client certificate, or an impersonation request. Each filter populates `request.state.security_context`; the `HttpSecurity` gate and `@secure` decorator then enforce access. Config-driven HTTP Basic and form login store their users with **pre-hashed bcrypt password hashes** — plaintext passwords never appear in configuration.
-When enabled, `OAuth2LoginHandler` (see [OAuth2 Login Flow](#oauth2-login-flow)) automatically:
+### UserDetails and the UserDetailsService SPI
-1. Generates a high-entropy `code_verifier` and its SHA-256 `code_challenge`.
-2. Adds `code_challenge` and `code_challenge_method=S256` to the authorization redirect, stashing the one-time `code_verifier` in the session.
-3. Sends the stored `code_verifier` when exchanging the authorization code for tokens.
+A `UserDetailsService` is the credential-lookup port: it resolves a username to a `UserDetails` (a stored password hash plus authorities) or `None`. The HTTP Basic / form-login / X.509 filters verify the supplied password against that hash using a `PasswordEncoder`.
-No additional wiring is required — toggling `use_pkce` is sufficient. The built-in `google()`, `github()`, and `keycloak()` factories default to `use_pkce=False`.
+`UserDetails` is a frozen dataclass:
-#### Built-in Provider Factories
+| Field | Type | Default | Description |
+|---|---|---|---|
+| `username` | `str` | required | The principal's identifier |
+| `password_hash` | `str` | required | Stored credential (e.g. a bcrypt hash) |
+| `roles` | `list[str]` | `[]` | Granted roles |
+| `permissions` | `list[str]` | `[]` | Granted permissions |
+| `enabled` | `bool` | `True` | Whether the account may authenticate |
-Pre-configured factories for common OAuth2 providers:
+The port is a single async method:
```python
-from pyfly.security.oauth2 import google, github, keycloak
+from typing import Protocol, runtime_checkable
+from pyfly.security import UserDetails
-# Google OAuth2
-google_reg = google(
- client_id="your-google-client-id",
- client_secret="your-google-client-secret",
- redirect_uri="https://myapp.com/callback/google",
-)
+@runtime_checkable
+class UserDetailsService(Protocol):
+ async def load_user_by_username(self, username: str) -> UserDetails | None: ...
+```
-# GitHub OAuth2
-github_reg = github(
- client_id="your-github-client-id",
- client_secret="your-github-client-secret",
+#### InMemoryUserDetailsService
+
+`InMemoryUserDetailsService` is a dict-backed store for development and testing. It takes any number of `UserDetails` and exposes `load_user_by_username()` plus an `add()` mutator:
+
+```python
+from pyfly.security import (
+ InMemoryUserDetailsService,
+ UserDetails,
+ BcryptPasswordEncoder,
)
-# Keycloak (derives all endpoints from the issuer URI)
-keycloak_reg = keycloak(
- client_id="your-keycloak-client-id",
- client_secret="your-keycloak-client-secret",
- issuer_uri="https://keycloak.example.com/realms/myrealm",
+encoder = BcryptPasswordEncoder(rounds=12)
+users = InMemoryUserDetailsService(
+ UserDetails(
+ username="alice",
+ password_hash=encoder.hash("s3cret"), # store the hash, not the password
+ roles=["ADMIN", "USER"],
+ permissions=["order:read", "order:write"],
+ ),
)
-```
+users.add(UserDetails(username="bob", password_hash=encoder.hash("hunter2"), roles=["USER"]))
-| Factory | Scopes | Grant Type |
-|---|---|---|
-| `google()` | `openid`, `profile`, `email` | `authorization_code` |
-| `github()` | `read:user`, `user:email` | `authorization_code` |
-| `keycloak()` | `openid`, `profile`, `email` | `authorization_code` |
+await users.load_user_by_username("alice") # -> UserDetails(...)
+await users.load_user_by_username("nobody") # -> None
+```
-#### ClientRegistrationRepository
+#### SqlUserDetailsService
-The `ClientRegistrationRepository` protocol defines the port for looking up registrations:
+`SqlUserDetailsService` is a durable, table-backed `UserDetailsService` for HTTP Basic / form login, backed by any SQLAlchemy `AsyncEngine`. It is hexagonal: the engine is supplied lazily via an `engine_factory` callable (the composition root injects it), and SQLAlchemy is never imported at module scope. The table is created lazily and idempotently on first use, with columns `username` (PK), `password_hash`, `roles` (JSON), `permissions` (JSON), and `enabled` (int). It works on PostgreSQL and SQLite via an `ON CONFLICT` upsert.
```python
-from pyfly.security.oauth2 import (
- ClientRegistrationRepository,
- InMemoryClientRegistrationRepository,
-)
+from pyfly.container import configuration, bean
+from pyfly.security.adapters.sql_user_details import SqlUserDetailsService
+from pyfly.security import UserDetails, UserDetailsService, BcryptPasswordEncoder
+from sqlalchemy.ext.asyncio import AsyncEngine
-# Create a repository with registrations
-repo = InMemoryClientRegistrationRepository(google_reg, github_reg, keycloak_reg)
-# Look up by registration ID
-reg = repo.find_by_registration_id("google") # Returns ClientRegistration or None
+@configuration
+class UserStoreConfig:
-# Add registrations after construction
-repo.add(custom_registration)
+ @bean
+ def user_details_service(self, engine: AsyncEngine) -> UserDetailsService:
+ # The engine is resolved from the container; the table defaults to "pyfly_users".
+ return SqlUserDetailsService(lambda: engine, table="pyfly_users")
+```
-# List all registrations
-all_regs = repo.registrations # list[ClientRegistration]
+```python
+# Provisioning and managing users (save() upserts by username; delete() removes one):
+store = SqlUserDetailsService(lambda: engine)
+await store.save(
+ UserDetails(
+ username="alice",
+ password_hash=BcryptPasswordEncoder().hash("s3cret"),
+ roles=["ADMIN"],
+ permissions=["order:write"],
+ enabled=True,
+ )
+)
+await store.load_user_by_username("alice") # -> UserDetails(...)
+await store.delete("alice")
```
-**Source:** `src/pyfly/security/oauth2/client.py`
+The constructor rejects an invalid SQL identifier as the table name (it must match `^[A-Za-z_][A-Za-z0-9_]*$`), raising `ValueError`.
+
+**Source:** `src/pyfly/security/user_details.py`, `src/pyfly/security/adapters/sql_user_details.py`
-### OAuth2 Authorization Server
+### AuthenticationManager: ProviderManager and DaoAuthenticationProvider
-The `AuthorizationServer` issues JWT access tokens and manages refresh tokens. It supports `client_credentials` (machine-to-machine) and `refresh_token` grant types.
+`ProviderManager` is PyFly's `AuthenticationManager`: it holds an ordered list of `AuthenticationProvider`s and authenticates an `Authentication` request by delegating to the first provider that `supports()` it. The built-in `DaoAuthenticationProvider` checks a username/password against a `UserDetailsService` and a `PasswordEncoder`.
+
+An `Authentication` is both the request and the result. Before authentication, `principal` and `credentials` carry the submitted username/password; after a successful authentication, `authenticated` is `True`, `roles` / `permissions` / `authorities` are populated, and `credentials` is erased. `to_security_context()` converts the (authenticated) result into a `SecurityContext`.
```python
-from pyfly.security.oauth2 import (
- AuthorizationServer,
- InMemoryTokenStore,
- InMemoryClientRegistrationRepository,
- ClientRegistration,
+from pyfly.security import (
+ Authentication,
+ DaoAuthenticationProvider,
+ ProviderManager,
+ InMemoryUserDetailsService,
+ UserDetails,
+ BcryptPasswordEncoder,
)
-# Set up client registration
-client = ClientRegistration(
- registration_id="my-service",
- client_id="my-service",
- client_secret="service-secret",
- scopes=["read", "write"],
-)
-client_repo = InMemoryClientRegistrationRepository(client)
-
-# Create authorization server
-auth_server = AuthorizationServer(
- secret="jwt-signing-secret",
- client_repository=client_repo,
- token_store=InMemoryTokenStore(),
- access_token_ttl=3600, # 1 hour
- refresh_token_ttl=86400, # 24 hours
- issuer="https://auth.myapp.com",
+encoder = BcryptPasswordEncoder(rounds=12)
+users = InMemoryUserDetailsService(
+ UserDetails(username="alice", password_hash=encoder.hash("s3cret"), roles=["ADMIN"]),
)
+
+manager = ProviderManager(DaoAuthenticationProvider(users, encoder))
+
+result = await manager.authenticate(Authentication(principal="alice", credentials="s3cret"))
+result.authenticated # True
+result.credentials # None -> erased on success
+result.authorities # ["ADMIN"] (roles + permissions)
+ctx = result.to_security_context() # SecurityContext(user_id="alice", roles=["ADMIN"], ...)
```
-**Constructor parameters:**
+`DaoAuthenticationProvider` behaviour, verified in source:
-| Parameter | Type | Default | Description |
-|---|---|---|---|
-| `secret` | `str` | required | Secret key for HS256 token signing |
-| `client_repository` | `ClientRegistrationRepository` | required | Repository for client lookup |
-| `token_store` | `TokenStore` | required | Storage for refresh tokens |
-| `access_token_ttl` | `int` | `3600` | Access token lifetime (seconds) |
-| `refresh_token_ttl` | `int` | `86400` | Refresh token lifetime (seconds) |
-| `issuer` | `str \| None` | `None` | Token issuer (`iss` claim) |
+- **Credential erasure.** A successful `authenticate()` returns an `Authentication` with `credentials=None`; `ProviderManager` also clears `credentials` on the returned result. `authorities` is the concatenation of `roles` and `permissions`.
+- **Timing equalisation.** When the username is unknown, the provider still runs `PasswordEncoder.verify()` against a throw-away dummy hash before raising, so request timing cannot be used to enumerate valid usernames.
+- **Failure modes.** An unknown user or a wrong password raises `BadCredentialsException` (code `"BAD_CREDENTIALS"`). The password is verified *before* the `enabled` check, so only a *correct* password against a disabled account raises `DisabledException` (code `"ACCOUNT_DISABLED"`); a wrong password on a disabled account still yields `BadCredentialsException`.
+- **`supports()`** returns `True` only when `principal` is non-empty and `credentials` is not `None`.
-#### Issuing Tokens
+`ProviderManager.authenticate()` iterates providers in order: it skips providers that do not `supports()` the request; if a supporting provider raises an `AuthenticationException` it remembers it and tries the next; the first authenticated result wins. If every supporting provider failed it re-raises the last error, and if no provider supported the request it raises `ProviderNotFoundException` (code `"PROVIDER_NOT_FOUND"`). Construct one from an iterable with `ProviderManager.of([...])`.
-```python
-# Client credentials grant (machine-to-machine)
-response = await auth_server.token(
- grant_type="client_credentials",
- client_id="my-service",
- client_secret="service-secret",
- scope="read write",
-)
-# {
-# "access_token": "eyJhbGciOiJIUzI1NiI...",
-# "token_type": "Bearer",
-# "expires_in": 3600,
-# "refresh_token": "dGhpcyBpcyBhIHJlZnJlc2g...",
-# "scope": "read write"
-# }
-
-# Refresh token grant
-new_response = await auth_server.token(
- grant_type="refresh_token",
- client_id="my-service",
- client_secret="service-secret",
- refresh_token=response["refresh_token"],
-)
-```
+All of these derive from `AuthenticationException` (a `SecurityException` subclass):
+
+| Exception | Code | Raised when |
+|---|---|---|
+| `BadCredentialsException` | `BAD_CREDENTIALS` | Unknown principal or wrong password |
+| `DisabledException` | `ACCOUNT_DISABLED` | Correct password but `enabled=False` |
+| `ProviderNotFoundException` | `PROVIDER_NOT_FOUND` | No provider `supports()` the request |
-**Refresh token rotation:** When a refresh token is used, the old token is automatically revoked and a new one is issued. This limits the window of vulnerability if a token is compromised.
+**Source:** `src/pyfly/security/authentication.py`
-#### TokenStore Protocol
+### Form Login
-The `TokenStore` protocol defines the port for token persistence:
+`FormLoginFilter` processes a POST of username/password to the login URL, authenticates via a `ProviderManager`, and on success **rotates the session id** (session-fixation defense) before storing the `SecurityContext` in the session — where `OAuth2SessionSecurityFilter` restores it on later requests. It runs at `HIGHEST_PRECEDENCE + 230` (after the session-restoring filter), so a successful login overrides any prior anonymous context. Both browser (302 redirect) and API (JSON) responses are supported via `use_redirect`.
+
+Enable config-driven form login by declaring **pre-hashed** users under `pyfly.security.form-login.users` (requires `starlette` and `bcrypt`). The auto-configuration builds a `ProviderManager(DaoAuthenticationProvider(InMemoryUserDetailsService(...), BcryptPasswordEncoder(...)))` from those users:
+
+```yaml
+pyfly:
+ security:
+ enabled: true
+ password:
+ bcrypt-rounds: 12 # cost factor for the encoder
+ form-login:
+ enabled: true
+ login-url: "/login" # POST target this filter intercepts
+ username-param: "username"
+ password-param: "password"
+ success-url: "/"
+ failure-url: "/login?error"
+ use-redirect: true # false -> JSON {"authenticated": true} / 401
+ users:
+ alice:
+ password-hash: "$2b$12$..." # bcrypt hash, never plaintext
+ roles: "ADMIN,USER" # comma-separated or a YAML list
+ permissions: "order:read,order:write"
+ enabled: true
+```
+
+For a dynamic user store (e.g. `SqlUserDetailsService`), register your own `FormLoginFilter` bean instead of using the config users:
```python
-class TokenStore(Protocol):
- async def store(self, token_id: str, token_data: dict[str, Any]) -> None: ...
- async def find(self, token_id: str) -> dict[str, Any] | None: ...
- async def revoke(self, token_id: str) -> None: ...
+from pyfly.container import configuration, bean
+from pyfly.web.ports.filter import WebFilter
+from pyfly.web.adapters.starlette.filters.form_login_filter import FormLoginFilter
+from pyfly.security import ProviderManager, DaoAuthenticationProvider, BcryptPasswordEncoder, UserDetailsService
+
+
+@configuration
+class FormLoginConfig:
+
+ @bean
+ def form_login_filter(self, users: UserDetailsService) -> WebFilter:
+ manager = ProviderManager(DaoAuthenticationProvider(users, BcryptPasswordEncoder(rounds=12)))
+ return FormLoginFilter(
+ manager,
+ login_url="/login",
+ success_url="/dashboard",
+ failure_url="/login?error",
+ use_redirect=True,
+ )
```
-`InMemoryTokenStore` is the built-in adapter for development and testing. PyFly also ships persistent Redis and Postgres adapters for production use (see below).
+On a failed login the filter catches `AuthenticationException` and returns the failure response (a redirect to `failure_url`, or `401` `{"error": "invalid_credentials"}` in API mode).
-#### Persistent Token Stores (multi-instance authorization server)
+**Source:** `src/pyfly/web/adapters/starlette/filters/form_login_filter.py`
-`InMemoryTokenStore` keeps refresh tokens in a process-local dict — it is fine for a single-instance dev/test server, but it **loses all tokens on restart** and is not shared across instances (a refresh issued on one node is unknown to another, and revocation does not propagate). To run the authorization server across multiple instances, select a persistent token store via configuration. `OAuth2AuthorizationServerAutoConfiguration._build_token_store()` reads `pyfly.security.oauth2.token-store.provider` (case-insensitive) and wires the matching adapter:
+### HTTP Basic
-| Provider | Adapter | Persistence | When to use |
-|---|---|---|---|
-| `memory` (default) | `InMemoryTokenStore` (`pyfly.security.oauth2.authorization_server`) | Process-local; **lost on restart**, not shared across instances | Development and testing only — single instance |
-| `redis` | `RedisTokenStore` (`pyfly.security.adapters.redis_token_store`) | Cross-instance, fast distributed revocation; tokens self-evict at the refresh-token TTL | Multi-instance servers wanting fast revocation |
-| `postgres` | `PostgresTokenStore` (`pyfly.security.adapters.postgres_token_store`) | Durable + auditable in a SQL table, no Redis required | Multi-instance servers needing durable, auditable storage |
+`HttpBasicAuthenticationFilter` parses an `Authorization: Basic` header (RFC 7617), resolves the user via a `UserDetailsService`, and verifies the password with a `PasswordEncoder` (offloaded to a worker thread, since bcrypt/argon2 verification is CPU-bound). It runs at `HIGHEST_PRECEDENCE + 215`, just before the symmetric JWT filter, so credential-based clients get a context while token-based auth falls through when no Basic header is present.
+
+`error_mode` controls what happens on a *present-but-invalid* credential:
-Selecting `redis` or `postgres` fixes multi-instance authorization servers: refresh tokens and revocations are shared across all nodes, so a token issued or revoked on one instance is honoured everywhere, and tokens survive a restart (postgres) or persist until their TTL (redis).
+- `"anonymous"` (default): a bad credential yields an anonymous context and the request proceeds — the `HttpSecurity` gate decides.
+- `"401"`: a present-but-invalid credential is rejected here with `401 Unauthorized`, a `WWW-Authenticate: Basic realm="…"` challenge, and body `{"error": "invalid_credentials", "error_description": "Authentication failed."}`.
+
+In either mode, a *missing* Basic header always falls through to the gate. The filter treats an unknown user, a disabled account (`enabled=False`), and a wrong password uniformly as an authentication failure.
+
+Enable config-driven HTTP Basic by declaring **pre-hashed** users under `pyfly.security.http-basic.users` (requires `starlette` and `bcrypt`):
```yaml
pyfly:
security:
- oauth2:
- authorization-server:
- enabled: true
- secret: "${OAUTH2_SECRET}"
- refresh-token-ttl: 86400 # also the Redis token TTL
- token-store:
- provider: redis # memory (default) | redis | postgres
- redis:
- url: "redis://localhost:6379/0" # falls back to pyfly.session.redis.url
+ enabled: true
+ password:
+ bcrypt-rounds: 12
+ http-basic:
+ enabled: true
+ realm: "PyFly"
+ error-mode: "401" # or "anonymous" (default)
+ users:
+ alice:
+ password-hash: "$2b$12$..." # bcrypt hash, never plaintext
+ roles: "ADMIN,USER"
+ permissions: "order:read"
+ enabled: true
```
-**Configuration keys:**
+For a dynamic user store, register the filter directly as a `WebFilter` bean:
-| Key | Default | Description |
-|---|---|---|
-| `pyfly.security.oauth2.token-store.provider` | `memory` | `memory`, `redis`, or `postgres` (matched case-insensitively) |
-| `pyfly.security.oauth2.token-store.redis.url` | falls back to `pyfly.session.redis.url`, then `redis://localhost:6379/0` | Redis connection URL (redis provider only) |
+```python
+from pyfly.container import configuration, bean
+from pyfly.web.ports.filter import WebFilter
+from pyfly.web.adapters.starlette.filters.http_basic_filter import HttpBasicAuthenticationFilter
+from pyfly.security import BcryptPasswordEncoder, UserDetailsService
-**Redis adapter.** When `provider=redis` and the `redis.asyncio` driver is available, the composition root builds an async client with `redis.asyncio.from_url(url)` and injects it into `RedisTokenStore(client, ttl=refresh_ttl)`. Tokens are stored as JSON under the `pyfly:oauth2:token:` key prefix with an `ex` equal to the refresh-token TTL, so expired tokens self-evict. If the driver is unavailable the store falls back to `InMemoryTokenStore`.
-**Postgres adapter.** When `provider=postgres`, the composition root resolves a SQLAlchemy `AsyncEngine` from the container (so an `AsyncEngine` bean must be present) and injects an engine factory into `PostgresTokenStore`. The adapter lazily and idempotently creates a `pyfly_oauth2_tokens` table (`token_id TEXT PRIMARY KEY, data TEXT NOT NULL`) on first use and upserts refresh tokens with `ON CONFLICT (token_id) DO UPDATE`. Both adapters are hexagonal: they never import their driver at module scope — the client/engine is injected by the auto-configuration.
+@configuration
+class HttpBasicConfig:
-#### Error Codes
+ @bean
+ def http_basic_filter(self, users: UserDetailsService) -> WebFilter:
+ return HttpBasicAuthenticationFilter(
+ users,
+ BcryptPasswordEncoder(rounds=12),
+ realm="PyFly",
+ error_mode="401", # or "anonymous"
+ )
+```
-| Error Code | Cause |
-|---|---|
-| `INVALID_CLIENT` | Unknown client ID or wrong secret |
-| `INVALID_REQUEST` | Missing required parameter (e.g., refresh_token) |
-| `UNSUPPORTED_GRANT_TYPE` | Grant type not supported |
-| `INVALID_GRANT` | Invalid, expired, or mismatched refresh token |
+You can generate a bcrypt hash for the config `password-hash` values with the built-in encoder:
+
+```bash
+python -c "from pyfly.security import BcryptPasswordEncoder; print(BcryptPasswordEncoder().hash('s3cret'))"
+```
-**Source:** `src/pyfly/security/oauth2/authorization_server.py`
+**Source:** `src/pyfly/web/adapters/starlette/filters/http_basic_filter.py`
-### OAuth2 Login Flow
+### X.509 Client-Certificate Authentication
-The `OAuth2LoginHandler` implements the full browser-facing OAuth2 `authorization_code` flow. It creates Starlette routes that handle the redirect-to-provider, callback-with-code, and logout steps. The `OAuth2SessionSecurityFilter` complements it by restoring the `SecurityContext` from the HTTP session on subsequent requests.
+`X509AuthenticationFilter` authenticates a request by the client certificate forwarded by a TLS-terminating proxy in a header (PEM, possibly URL-encoded). It runs at `HIGHEST_PRECEDENCE + 218`. The certificate subject's Common Name becomes the principal; alternatively a `subject_regex` with a capturing group extracts the principal from the subject's RFC 4514 string (the first capture group is used). There is no auto-configuration for X.509 — register the filter as a `WebFilter` bean.
+
+Behaviour:
+
+- **No `UserDetailsService`** — certificate presence *is* the credential: the principal authenticates with no authority lookup (`SecurityContext(user_id=)`).
+- **With a `UserDetailsService`** — the extracted principal must resolve to an enabled user, whose roles/permissions are applied; an unknown or disabled user fails.
+- On failure, `error_mode="401"` returns `401` `{"error": "invalid_client_certificate"}` with a `WWW-Authenticate: X509` header; `"anonymous"` (default) sets an anonymous context and proceeds. A *missing* certificate header always falls through.
```python
-from pyfly.security.oauth2.login import OAuth2LoginHandler
-from pyfly.security.oauth2.session_security_filter import OAuth2SessionSecurityFilter
-```
+from pyfly.container import configuration, bean
+from pyfly.web.ports.filter import WebFilter
+from pyfly.web.adapters.starlette.filters.x509_filter import X509AuthenticationFilter
+from pyfly.security import UserDetailsService
-#### OAuth2LoginHandler
-`OAuth2LoginHandler` creates three routes:
+@configuration
+class X509Config:
-| Route | Method | Description |
-|---|---|---|
-| `/oauth2/authorization/{registration_id}` | GET | Redirects the browser to the OAuth2 provider's authorization endpoint with a CSRF `state` parameter |
-| `/login/oauth2/code/{registration_id}` | GET | Handles the provider callback: validates state, exchanges the authorization code for tokens, fetches user info, builds a `SecurityContext`, and stores it in the session |
-| `/logout` | POST | Invalidates the HTTP session and redirects to `/` |
+ @bean
+ def x509_filter(self, users: UserDetailsService) -> WebFilter:
+ return X509AuthenticationFilter(
+ cert_header="x-client-cert", # header the proxy forwards (PEM)
+ user_details_service=users, # omit to authenticate on cert presence alone
+ subject_regex=r"CN=([^,]+)", # optional; default extracts the CN
+ error_mode="401", # or "anonymous"
+ )
+```
-**Constructor parameters:**
+**Source:** `src/pyfly/web/adapters/starlette/filters/x509_filter.py`
-| Parameter | Type | Description |
-|---|---|---|
-| `client_repository` | `ClientRegistrationRepository` | Repository to look up OAuth2 client registrations |
+### Logout
-**Authorization flow:**
+`LogoutFilter` handles a POST to the logout URL — independent of OAuth2 — by invalidating the HTTP session, clearing the security context to anonymous, and deleting configured cookies. It runs at `HIGHEST_PRECEDENCE + 235` (after form login). With `use_redirect=True` it returns a `302` to the success URL; otherwise it returns `204 No Content`.
-1. The user visits `/oauth2/authorization/google` (or any registration ID).
-2. The handler looks up the `ClientRegistration`, generates a random `state` token, stores it in the session, and redirects the browser to the provider's `authorization_uri` with `response_type=code`, `client_id`, `redirect_uri`, `scope`, and `state` parameters. If the registration has [`use_pkce=True`](#pkce-proof-key-for-code-exchange), a `code_challenge` (`code_challenge_method=S256`) is also added and the matching `code_verifier` is stored in the session for the token exchange.
-3. The provider authenticates the user and redirects back to `/login/oauth2/code/google?code=...&state=...`.
-4. The callback handler validates the `state` parameter (CSRF protection), exchanges the authorization code for tokens via the provider's `token_uri`, fetches user info from `user_info_uri`, builds a `SecurityContext`, and stores it in the session.
-5. The user is redirected to the original page (or `/`).
+Enable config-driven logout (requires `starlette`):
+
+```yaml
+pyfly:
+ security:
+ logout:
+ enabled: true
+ logout-url: "/logout" # POST target this filter intercepts
+ success-url: "/login?logout" # redirect target (use-redirect=true)
+ delete-cookies: "SESSION,XSRF-TOKEN" # comma-separated or a YAML list
+ use-redirect: true # false -> 204 No Content
+```
+
+Or register the filter programmatically:
```python
-from pyfly.security.oauth2 import (
- ClientRegistrationRepository,
- InMemoryClientRegistrationRepository,
- google,
-)
-from pyfly.security.oauth2.login import OAuth2LoginHandler
+from pyfly.container import configuration, bean
+from pyfly.web.ports.filter import WebFilter
+from pyfly.web.adapters.starlette.filters.logout_filter import LogoutFilter
-# Set up client registrations
-google_reg = google(
- client_id="your-google-client-id",
- client_secret="your-google-client-secret",
- redirect_uri="http://localhost:8080/login/oauth2/code/google",
-)
-client_repo = InMemoryClientRegistrationRepository(google_reg)
-# Create the login handler
-login_handler = OAuth2LoginHandler(client_repository=client_repo)
+@configuration
+class LogoutConfig:
-# Get the routes for mounting in create_app()
-oauth2_routes = login_handler.routes()
+ @bean
+ def logout_filter(self) -> WebFilter:
+ return LogoutFilter(
+ logout_url="/logout",
+ logout_success_url="/login?logout",
+ delete_cookies=["SESSION", "XSRF-TOKEN"],
+ use_redirect=True,
+ )
```
-**Source:** `src/pyfly/security/oauth2/login.py`
+Each deleted cookie is cleared with `path="/"`.
+
+**Source:** `src/pyfly/web/adapters/starlette/filters/logout_filter.py`
+
+### switch-user / run-as Impersonation
+
+`SwitchUserFilter` lets an authorized principal impersonate another user and switch back, mirroring Spring's `SwitchUserFilter`. It runs at `HIGHEST_PRECEDENCE + 232` (after form login, before logout) and matches on path (the target username comes from a query parameter). There is no auto-configuration — register it as a `WebFilter` bean with a `UserDetailsService`.
-#### OAuth2SessionSecurityFilter
+Flow:
-The `OAuth2SessionSecurityFilter` is a `OncePerRequestFilter` that restores the `SecurityContext` from the HTTP session on every request. It runs at `HIGHEST_PRECEDENCE + 225`, which is **after** the JWT-based `SecurityFilter` (at +220), so a session-established context overwrites a token-established one — session-based authentication takes priority over symmetric-token auth.
+1. The acting principal visits the **switch URL** (default `/login/impersonate`) with `?username=`. They must be authenticated, and must hold the **switch authority** (default `ADMIN`) as either a role or a permission; otherwise the filter returns `401` (`authentication_required`) or `403` (`forbidden`).
+2. The target must resolve to an enabled user, else `404` (`user_not_found`).
+3. On success the filter builds an impersonated `SecurityContext` carrying the target's roles **plus** the marker role `PREVIOUS_ADMINISTRATOR` (the value of `PREVIOUS_PRINCIPAL_ROLE`). It stashes the full original `SecurityContext` in the session (under the internal `SWITCH_USER_ORIGINAL` key) so it can be restored, and records the original principal id on the impersonated context's `switch_user_original` attribute. It then redirects to `success_url`. The marker lets the application detect run-as and offer an "exit" action.
+4. Visiting the **exit URL** (default `/logout/impersonate`) restores the original context and redirects to `success_url`; if there is no stashed original it returns `400` (`not_impersonating`).
```python
-from pyfly.security.oauth2.session_security_filter import OAuth2SessionSecurityFilter
+from pyfly.container import configuration, bean
+from pyfly.web.ports.filter import WebFilter
+from pyfly.web.adapters.starlette.filters.switch_user_filter import SwitchUserFilter
+from pyfly.security import UserDetailsService
+
+
+@configuration
+class SwitchUserConfig:
+
+ @bean
+ def switch_user_filter(self, users: UserDetailsService) -> WebFilter:
+ return SwitchUserFilter(
+ users,
+ switch_url="/login/impersonate", # GET ?username=
+ exit_url="/logout/impersonate",
+ username_param="username",
+ switch_authority="ADMIN", # required role OR permission
+ success_url="/",
+ )
```
-**Behavior:**
+An impersonated request can be recognised with `security_context.has_role("PREVIOUS_ADMINISTRATOR")`, and the original principal read from `security_context.attributes["switch_user_original"]`.
-1. Reads the session from `request.state.session`.
-2. If a `SECURITY_CONTEXT` attribute is stored in the session (set by `OAuth2LoginHandler` during login), restores it to `request.state.security_context`.
-3. If no session-based context is found and no `security_context` has been set by an earlier filter, sets an anonymous context.
+**Source:** `src/pyfly/web/adapters/starlette/filters/switch_user_filter.py`
-This filter is complementary to the JWT `SecurityFilter`. In applications that use both OAuth2 login (session-based) and API tokens (JWT-based), the session filter runs first. If the user has an active session, the session context is used. If not, the JWT `SecurityFilter` gets its turn to check for a Bearer token.
+---
-| Property | Value |
-|---|---|
-| `__pyfly_order__` | `HIGHEST_PRECEDENCE + 225` |
-| Runs after | `SecurityFilter` (HP+220) |
-| Runs before | `HttpSecurityFilter` (HP+350) |
+## Security Headers
-**Source:** `src/pyfly/security/oauth2/session_security_filter.py`
+`SecurityHeadersFilter` adds OWASP-recommended response headers to **every** response. It is an `OncePerRequestFilter` ordered at `HIGHEST_PRECEDENCE + 300`, and appends a precomputed, static set of header pairs after the downstream handler returns. Header names and values come from `SecurityHeadersConfig` (a frozen dataclass); the table below lists the exact headers emitted with their defaults:
-#### Login Flow Configuration Example
+| Header | Default value | Notes |
+|---|---|---|
+| `x-content-type-options` | `nosniff` | always emitted |
+| `x-frame-options` | `DENY` | always emitted |
+| `strict-transport-security` | `max-age=31536000; includeSubDomains` | always emitted |
+| `x-xss-protection` | `0` | always emitted (modern browsers: disable the legacy XSS auditor) |
+| `referrer-policy` | `strict-origin-when-cross-origin` | always emitted |
+| `content-security-policy` | *(unset)* | only emitted when `content_security_policy` is configured (default `None` = not added — CSP is too app-specific) |
+| `permissions-policy` | *(unset)* | only emitted when `permissions_policy` is configured (default `None` = not added) |
-A complete example wiring OAuth2 login into a PyFly application:
+To customise, construct the filter with a `SecurityHeadersConfig`:
```python
-from pyfly.container import configuration, bean
-from pyfly.security.oauth2 import (
- InMemoryClientRegistrationRepository,
- google, github,
+from pyfly.web.adapters.starlette.filters.security_headers_filter import SecurityHeadersFilter
+from pyfly.web.security_headers import SecurityHeadersConfig
+
+filter_ = SecurityHeadersFilter(
+ SecurityHeadersConfig(
+ x_frame_options="SAMEORIGIN",
+ content_security_policy="default-src 'self'",
+ permissions_policy="geolocation=(), camera=()",
+ )
)
-from pyfly.security.oauth2.login import OAuth2LoginHandler
-from pyfly.security.oauth2.session_security_filter import OAuth2SessionSecurityFilter
-from pyfly.security.http_security import HttpSecurity
+```
+**Source:** `src/pyfly/web/adapters/starlette/filters/security_headers_filter.py`, `src/pyfly/web/security_headers.py`
-@configuration
-class OAuth2Config:
+---
- @bean
- def client_repository(self) -> InMemoryClientRegistrationRepository:
- return InMemoryClientRegistrationRepository(
- google(
- client_id="google-client-id",
- client_secret="google-client-secret",
- redirect_uri="http://localhost:8080/login/oauth2/code/google",
- ),
- github(
- client_id="github-client-id",
- client_secret="github-client-secret",
- redirect_uri="http://localhost:8080/login/oauth2/code/github",
- ),
- )
+## OAuth 2.1 & OpenID Connect
- @bean
- def oauth2_login_handler(self, client_repository: InMemoryClientRegistrationRepository) -> OAuth2LoginHandler:
- return OAuth2LoginHandler(client_repository=client_repository)
+PyFly ships a complete OAuth 2.1 / OpenID Connect implementation across all three roles — **resource server** (validate inbound tokens), **client & login** (the browser `authorization_code` flow with PKCE), and a full **authorization server** (issue tokens; `client_credentials`, `refresh_token`, and `authorization_code` grants, OIDC id tokens, JWKS, introspection/revocation, Dynamic Client Registration, PAR, JAR, metadata/discovery) — plus sender-constrained (DPoP / mTLS) tokens.
- @bean
- def oauth2_session_filter(self) -> OAuth2SessionSecurityFilter:
- return OAuth2SessionSecurityFilter()
+That surface is documented in its own guide:
- @bean
- def http_security_filter(self):
- http_security = HttpSecurity()
- http_security.authorize_requests() \
- .request_matchers("/oauth2/**", "/login/**", "/logout").permit_all() \
- .request_matchers("/api/**").authenticated() \
- .any_request().permit_all()
- return http_security.build()
+**→ [OAuth 2.1 & OpenID Connect](oauth2.md)**
+
+```yaml
+pyfly:
+ security:
+ oauth2:
+ resource-server: # validate JWTs from any OIDC IdP
+ enabled: true
+ issuer-uri: "https://login.example.com/realms/app"
+ audiences: "my-api"
```
-Then mount the OAuth2 routes via `extra_routes` in `create_app()`:
+The resource server validates bearer tokens against a remote JWKS with config-driven claim mapping; the client supports declarative `ClientRegistration`s with PKCE on by default; the authorization server issues and manages tokens. See the [OAuth2 guide](oauth2.md) for the resource server, client/login, authorization server, DPoP/mTLS, and the full configuration reference.
-```python
-from pyfly.web.adapters.starlette import create_app
+---
-login_handler = context.get_bean(OAuth2LoginHandler)
-app = create_app(
- title="My App",
- context=context,
- extra_routes=login_handler.routes(),
-)
+## Secure-by-Default & Hardening
+
+PyFly's security defaults are chosen to fail closed. The behaviours below are active without extra configuration; operators should understand them before deploying.
+
+**Signing-secret fail-fast.** The composition root refuses to start when a token-signing secret is left at the built-in placeholder `change-me-in-production`, raising `SecurityException` with code `INSECURE_SIGNING_SECRET`. For HMAC (`HS*`) algorithms it additionally requires at least 32 bytes (RFC 7518 §3.2), raising `WEAK_SIGNING_SECRET` otherwise. This is enforced for the authorization-server secret (`pyfly.security.oauth2.authorization-server.secret`) unconditionally. The symmetric `JWTService` secret (`pyfly.security.jwt.secret`) is only enforced when the symmetric JWT filter is enabled (`pyfly.security.jwt.filter.enabled=true`) — a resource-server-only app validates JWTs via JWKS and never needs a symmetric signing secret.
+
+```bash
+# Generate a strong secret:
+python -c "import secrets; print(secrets.token_urlsafe(48))"
```
+**CSRF on by default (cookie-gated).** CSRF protection is enabled unless `pyfly.security.csrf.enabled=false`; cookie-gated mode keeps stateless/Bearer clients unaffected. Set `pyfly.security.csrf.cookie-gated=false` for strict enforcement of every unsafe request. (See [Enabled by Default](#enabled-by-default-cookie-gated).)
+
+**PKCE on by default.** `ClientRegistration.use_pkce` defaults to `True` for the `authorization_code` flow (RFC 9700 / OAuth 2.1). A public client (empty `client_secret`) always uses PKCE with `S256` even if `use_pkce=False`, since it has no other defense against code injection; only set `use_pkce=False` for a confidential client talking to an authorization server that rejects PKCE. The RFC 9207 `iss` authorization-response parameter is validated whenever present; set `require_iss=true` (per registration) to also reject providers that omit it.
+
+**ROPC opt-in.** The Resource Owner Password Credentials grant (`grant_type=password`) against external IdPs (`keycloak` / `cognito` / `azure-ad`) is disabled unless `pyfly.idp.allow-password-grant=true`.
+
+**client_credentials scope validation.** The `AuthorizationServer` rejects a `client_credentials` request that asks for scopes not registered for the client, returning the `INVALID_SCOPE` error.
+
+**Refresh-token rotation + reuse detection.** Refresh tokens are single-use and rotated on every refresh; the old token is revoked when a new one is issued. Reusing an already-rotated (revoked) token triggers family reuse detection — the token family is revoked — and the request is rejected with `INVALID_GRANT`.
+
+**Source:** `src/pyfly/security/auto_configuration.py`, `src/pyfly/web/security_filters_auto_configuration.py`, `src/pyfly/security/oauth2/client.py`, `src/pyfly/security/oauth2/authorization_server.py`
+
---
## Exception Hierarchy
diff --git a/docs/modules/web-filters.md b/docs/modules/web-filters.md
index 7f9c6aeb..d617c355 100644
--- a/docs/modules/web-filters.md
+++ b/docs/modules/web-filters.md
@@ -46,12 +46,18 @@ WebFilterChainMiddleware (pure ASGI middleware)
+-- CorrelationFilter (@order HIGHEST_PRECEDENCE + 50)
+-- TransactionIdFilter (@order HIGHEST_PRECEDENCE + 100)
+-- RequestLoggingFilter (@order HIGHEST_PRECEDENCE + 200)
+ +-- CsrfFilter (@order HIGHEST_PRECEDENCE + 210, on by default)
+ +-- HttpBasicAuthenticationFilter (@order HIGHEST_PRECEDENCE + 215, opt-in)
+ +-- X509AuthenticationFilter (@order HIGHEST_PRECEDENCE + 218, opt-in)
+-- SecurityFilter (@order HIGHEST_PRECEDENCE + 220, opt-in JWT auth)
+-- OAuth2SessionSecurityFilter (@order HIGHEST_PRECEDENCE + 225, opt-in)
+ +-- FormLoginFilter (@order HIGHEST_PRECEDENCE + 230, opt-in)
+-- IdempotencyWebFilter (@order HIGHEST_PRECEDENCE + 230, opt-in)
+ +-- SwitchUserFilter (@order HIGHEST_PRECEDENCE + 232, opt-in)
+ +-- LogoutFilter (@order HIGHEST_PRECEDENCE + 235, opt-in)
+ +-- OAuth2ResourceServerFilter (@order HIGHEST_PRECEDENCE + 250, opt-in)
+-- SecurityHeadersFilter (@order HIGHEST_PRECEDENCE + 300)
+-- HttpSecurityFilter (@order HIGHEST_PRECEDENCE + 350, opt-in)
- +-- CsrfFilter (__pyfly_order__ = -50, opt-in)
+-- [User WebFilter beans, sorted by @order]
|
v
diff --git a/docs/spring-comparison.md b/docs/spring-comparison.md
index 60cfee6c..54bfcf16 100644
--- a/docs/spring-comparison.md
+++ b/docs/spring-comparison.md
@@ -1484,11 +1484,66 @@ class ShoppingCart: ...
| `#paramName` / `returnObject` | `#paramName` / `returnObject` | Method args are bound by name; `returnObject` is available in `@post_authorize`. (`@secure` does not bind args.) |
| `RoleHierarchy` bean | `RoleHierarchy` + `set_role_hierarchy()` / `get_role_hierarchy()` | `RoleHierarchy.from_string("ADMIN > USER")`, `expand(roles)`; one process-wide hierarchy consulted by `hasRole`/`hasAnyRole`/`hasAuthority`. See [Security Guide](modules/security.md#method-level-security). |
+### Authentication (login mechanisms + credential SPI)
+
+| Spring Security | PyFly | Notes |
+|-----------------|-------|-------|
+| `http.formLogin()` / `UsernamePasswordAuthenticationFilter` | `FormLoginFilter` (`pyfly.web.adapters.starlette.filters.form_login_filter`) | POST of `username`/`password` to the login URL; authenticates via `ProviderManager`, rotates the session id (session-fixation defense), stores the `SecurityContext` in the session. Redirect or JSON via `use_redirect`. Enable with `pyfly.security.form-login.enabled`; tune `.login-url`, `.username-param`, `.password-param`, `.success-url`, `.failure-url`, `.use-redirect`, `.users..{password-hash, roles, permissions, enabled}`. |
+| `http.httpBasic()` / `BasicAuthenticationFilter` | `HttpBasicAuthenticationFilter` (`...filters.http_basic_filter`) | RFC 7617 Basic auth against a `UserDetailsService` + `PasswordEncoder`. `error-mode: anonymous` (default — fall through to the gate) or `401` (challenge with `WWW-Authenticate: Basic realm=…`). Enable with `pyfly.security.http-basic.enabled`; `.realm`, `.error-mode`, `.users..{password-hash, roles, permissions, enabled}`. |
+| `http.x509()` / `X509AuthenticationFilter` | `X509AuthenticationFilter` (`...filters.x509_filter`) | Authenticates the client cert forwarded by the TLS-terminating proxy in a header (default `x-client-cert`, PEM, URL-decoded); subject CN → principal, optional `UserDetailsService` supplies authorities. `error_mode` `anonymous`/`401`. Wired programmatically. |
+| `SwitchUserFilter` (`/login/impersonate`, `ROLE_PREVIOUS_ADMINISTRATOR`) | `SwitchUserFilter` (`...filters.switch_user_filter`) | A principal holding `switch_authority` (default `ADMIN`) impersonates another user; original principal stashed in session and restored at the exit URL. Defaults: switch `/login/impersonate`, exit `/logout/impersonate`; impersonated context carries the `PREVIOUS_ADMINISTRATOR` authority. |
+| `http.logout()` / `LogoutConfigurer` | `LogoutFilter` (`...filters.logout_filter`) | POST to the logout URL invalidates the session, clears the context, deletes cookies. Enable with `pyfly.security.logout.enabled`; `.logout-url`, `.success-url`, `.delete-cookies`, `.use-redirect`. |
+| `UserDetailsService` / `UserDetails` / `InMemoryUserDetailsManager` | `UserDetailsService` (protocol), `UserDetails`, `InMemoryUserDetailsService`; JDBC equivalent `SqlUserDetailsService` (`pyfly.security.adapters.sql_user_details`) | `async load_user_by_username(username) -> UserDetails | None`. `UserDetails` holds `username`, `password_hash`, `roles`, `permissions`, `enabled`. |
+| `AuthenticationManager` / `ProviderManager` / `DaoAuthenticationProvider` | `ProviderManager`, `AuthenticationProvider`, `DaoAuthenticationProvider`, `Authentication` (`pyfly.security.authentication`) | `ProviderManager` consults providers in order (first that `supports` wins). `DaoAuthenticationProvider` verifies username/password against a `UserDetailsService` + `PasswordEncoder`, with a constant-time dummy `verify()` on unknown users to block username enumeration. Exceptions mirror Spring: `AuthenticationException`, `BadCredentialsException`, `DisabledException`, `ProviderNotFoundException`. |
+
+### Password encoding (algorithm migration)
+
+| Spring Security | PyFly | Notes |
+|-----------------|-------|-------|
+| `PasswordEncoderFactories.createDelegatingPasswordEncoder()` / `DelegatingPasswordEncoder` | `DelegatingPasswordEncoder` / `create_delegating_password_encoder()` | Produces `{id}`-prefixed hashes (`{bcrypt}…`) and dispatches `verify()` by prefix; `upgrade_encoding(hash)` reports when a stored hash should be re-hashed with the current default (transparent on-login migration). Default id is `bcrypt`. Enable the auto-config encoder with `pyfly.security.password.delegating.enabled`; bcrypt cost via `pyfly.security.password.bcrypt-rounds`. |
+| `Argon2PasswordEncoder` / `Pbkdf2PasswordEncoder` / `SCryptPasswordEncoder` / `BCryptPasswordEncoder` | `Argon2PasswordEncoder`, `Pbkdf2PasswordEncoder`, `ScryptPasswordEncoder`, `BcryptPasswordEncoder` (all implement the `PasswordEncoder` protocol) | PBKDF2 (600k SHA-256 iters), scrypt and Argon2id all emit self-describing strings. `Argon2PasswordEncoder` needs `argon2-cffi` (`pip install pyfly[argon2]`); it is imported lazily so the module works without it. |
+
+### Authorization (filters + expression DSL)
+
+| Spring Security | PyFly | Notes |
+|-----------------|-------|-------|
+| `@PreFilter` / `@PostFilter` | `pre_filter` / `post_filter` (`pyfly.security.method_security`) | Filter a collection argument before the call (`@PreFilter`) or the returned collection after (`@PostFilter`); each element binds to `filterObject`, and the concrete collection type is preserved. |
+| `PermissionEvaluator` (ACL `hasPermission`) | `PermissionEvaluator` (protocol) + `set_permission_evaluator()` / `get_permission_evaluator()` | Backs `hasPermission(target, 'perm')` and `hasPermission(id, 'Type', 'perm')` in method-security expressions with domain-object checks. Install one process-wide. |
+| `requestMatchers(HttpMethod.POST, "/…")` | `HttpSecurity.authorize_requests().request_matchers("/…", methods="POST")` (also `any_request(methods=…)`) | `SecurityRule.methods` restricts a rule to specific HTTP methods; an empty list matches any method. Terminals unchanged: `permit_all()`, `deny_all()`, `authenticated()`, `has_role()`, `has_any_role()`, `has_permission()`. |
+| `CsrfFilter` (on by default, `CookieCsrfTokenRepository`) | `CsrfFilter` (`...filters.csrf_filter`) via `CsrfFilterAutoConfiguration` | **Secure by default** (`match_if_missing=true`): double-submit cookie active unless `pyfly.security.csrf.enabled=false`. Cookie-gated mode (`pyfly.security.csrf.cookie-gated`, default `true`) exempts requests with no cookies and `Authorization: Bearer …` requests; set `cookie-gated=false` for strict enforcement. Skip paths with `pyfly.security.csrf.exclude-patterns`. |
+| `HeaderWriterFilter` / `http.headers()` defaults | `SecurityHeadersFilter` + `SecurityHeadersConfig` (`pyfly.web.security_headers`) | Added by `create_app()` for every response by default: `X-Content-Type-Options`, `X-Frame-Options`, `Strict-Transport-Security`, `X-XSS-Protection`, `Referrer-Policy`, plus optional `Content-Security-Policy` / `Permissions-Policy`. |
+
+### OAuth2 Authorization Server
+
+| Spring Security | PyFly | Notes |
+|-----------------|-------|-------|
+| Spring Authorization Server (`OAuth2AuthorizationServerConfigurer`) | `AuthorizationServer` + `AuthorizationServerEndpoints` (`pyfly.security.oauth2`) | Enable with `pyfly.security.oauth2.authorization-server.enabled`; `secret`, `issuer`, `audience`, `access-token-ttl`, `refresh-token-ttl`. Startup FAILS if `secret` is the placeholder `change-me-in-production` or (HS*) shorter than 32 bytes. |
+| `client_credentials` grant | `AuthorizationServer.token(grant_type="client_credentials")` | Rejects scopes not registered for the client (`INVALID_SCOPE`); a client registered only for another grant cannot mint client-credentials tokens. |
+| `refresh_token` grant (rotation) | `AuthorizationServer.token(grant_type="refresh_token")` | Rotation + **family reuse detection**: using a refresh token revokes it and issues a new one; replaying a consumed token deactivates the whole family. |
+| `authorization_code` + PKCE (OAuth 2.1) | `AuthorizationServer` authorization-code grant | Single-use code, exact `redirect_uri` match, **mandatory PKCE S256**; a replayed code revokes previously issued tokens. |
+| OIDC `id_token` | OIDC `id_token` for the `openid` scope | Issued alongside the access token when `openid` is requested. |
+| JWKS endpoint + asymmetric signing | `AuthorizationServer.jwks()`; `algorithm` HS256 (default) or RS256/RS384/RS512/PS*/ES256/ES384/ES512 | `GET /oauth2/jwks` publishes public keys for asymmetric signing. |
+| Token introspection (RFC 7662) / revocation (RFC 7009) | `POST /oauth2/introspect` / `POST /oauth2/revoke` | Client-authenticated; RFC 7009 §2.1 only the owning client may revoke. |
+| Dynamic client registration | `AuthorizationServer.register_client()` → `POST /oauth2/register` | Registers a client and returns its credentials. |
+| Pushed Authorization Requests (RFC 9126) | `POST /oauth2/par` | Returns a one-time `request_uri` consumed by `/oauth2/authorize`. |
+| JWT-secured Authorization Request (RFC 9101, JAR) | `request=` on `/oauth2/authorize`, verified via `verify_request_object()` | Signed request object (HS256, client secret) supplies the authorization parameters. |
+| Authorization Server Metadata / OIDC discovery | `GET /.well-known/oauth-authorization-server`, `GET /.well-known/openid-configuration` | Advertises endpoints, `code_challenge_methods_supported=["S256"]`, and (OIDC) `id_token_signing_alg_values_supported`. |
+| RFC 9207 `iss` mix-up defense | `iss` emitted in the authorize redirect; validated by `OAuth2LoginHandler`; `ClientRegistration.require_iss` | `iss` is validated when present; `require_iss=true` (`pyfly.security.oauth2.client.registrations..require-iss`) additionally requires it to be present. |
+| Persistent token store (multi-instance) | `TokenStore` / `InMemoryTokenStore`, `RedisTokenStore`, `PostgresTokenStore` | Select via `pyfly.security.oauth2.token-store.provider` (`memory`\|`redis`\|`postgres`); `…token-store.redis.url`. |
+
+### OAuth2 Resource Server hardening
+
+| Spring Security | PyFly | Notes |
+|-----------------|-------|-------|
+| `OpaqueTokenIntrospector` / `NimbusOpaqueTokenIntrospector` | `OpaqueTokenIntrospector` (`pyfly.security.oauth2`) | Validates opaque (non-JWT) access tokens via a remote RFC 7662 `/introspect` endpoint; maps claims onto `SecurityContext` identically to `JWKSTokenValidator` (shared `build_security_context`). |
+| DPoP (RFC 9449) sender-constrained tokens | `DPoPProofValidator`, `confirm_dpop_binding`, `jwk_thumbprint`, `access_token_hash` (`pyfly.security.oauth2.dpop`); enforced by `OAuth2ResourceServerFilter` | When a token carries `cnf.jkt` AND `pyfly.security.oauth2.resource-server.enforce-sender-constraints=true`, the filter requires a valid `DPoP` proof bound to that key. |
+| mTLS-bound tokens (RFC 8705) | `confirm_mtls_binding`, `certificate_thumbprint` (`...oauth2.dpop`); `OAuth2ResourceServerFilter` | When a token carries `cnf["x5t#S256"]` and enforcement is on, the presented client cert (from `…resource-server.mtls-cert-header`, default `x-client-cert`) must match the thumbprint. |
+
### OAuth2 PKCE
| Spring | PyFly | Notes |
|--------|-------|-------|
-| `ClientRegistration` PKCE (`code_challenge`/S256) | `ClientRegistration(use_pkce=True)` | Toggling `use_pkce` makes `OAuth2LoginHandler` generate a `code_verifier`/`code_challenge` (S256) on the `authorization_code` flow. No extra wiring. See [Security Guide](modules/security.md#pkce-proof-key-for-code-exchange). |
+| `ClientRegistration` PKCE (`code_challenge`/S256) | `ClientRegistration` (`use_pkce` defaults `True`) | PKCE is **on by default** on the `authorization_code` flow and always forced for public (empty-secret) clients; `OAuth2LoginHandler` generates the `code_verifier`/`code_challenge` (S256). See the [OAuth2 Guide](modules/oauth2.md#pkce-on-by-default). |
### Distributed trace propagation (OTel)
@@ -1603,7 +1658,32 @@ A complete mapping of Spring Boot concepts to PyFly equivalents:
| `@PreAuthorize` / `@PostAuthorize` | `@pre_authorize` / `@post_authorize` | Method-level SpEL security |
| SpEL `#param` / `returnObject` | `#param` / `returnObject` | Bound method args / return value in expressions |
| `RoleHierarchy` bean | `RoleHierarchy` + `set_role_hierarchy()` | Transitive role expansion |
-| `ClientRegistration` PKCE | `ClientRegistration(use_pkce=True)` | OAuth2 PKCE (RFC 7636, S256) |
+| `ClientRegistration` PKCE | `ClientRegistration` (`use_pkce` defaults `True`) | OAuth2 PKCE (RFC 7636, S256); on by default, always forced for public (empty-secret) clients |
+| `http.formLogin()` | `FormLoginFilter` / `pyfly.security.form-login.*` | Username/password form login + session |
+| `http.httpBasic()` | `HttpBasicAuthenticationFilter` / `pyfly.security.http-basic.*` | RFC 7617 Basic auth |
+| `http.x509()` | `X509AuthenticationFilter` | Forwarded client-cert auth |
+| `SwitchUserFilter` | `SwitchUserFilter` | Run-as user impersonation |
+| `http.logout()` | `LogoutFilter` / `pyfly.security.logout.*` | Session invalidation + cookie clearing |
+| `UserDetailsService` / `UserDetails` | `UserDetailsService` / `UserDetails` / `InMemoryUserDetailsService` / `SqlUserDetailsService` | Credential-lookup SPI |
+| `AuthenticationManager` / `DaoAuthenticationProvider` | `ProviderManager` / `DaoAuthenticationProvider` / `Authentication` | Pluggable authentication providers |
+| `DelegatingPasswordEncoder` / `PasswordEncoderFactories` | `DelegatingPasswordEncoder` / `create_delegating_password_encoder()` | `{id}`-prefixed hashes + on-login migration |
+| `Argon2/Pbkdf2/SCrypt/BCryptPasswordEncoder` | `Argon2PasswordEncoder` / `Pbkdf2PasswordEncoder` / `ScryptPasswordEncoder` / `BcryptPasswordEncoder` | `PasswordEncoder` adapters |
+| `@PreFilter` / `@PostFilter` | `pre_filter` / `post_filter` | Collection filtering (`filterObject`) |
+| `PermissionEvaluator` | `PermissionEvaluator` + `set_permission_evaluator()` | ACL-style `hasPermission` |
+| `requestMatchers(HttpMethod.X, …)` | `request_matchers(…, methods="X")` | HTTP-method-aware URL rules |
+| `CsrfFilter` (on by default) | `CsrfFilter` / `pyfly.security.csrf.*` | Double-submit cookie, secure by default |
+| `HeaderWriterFilter` / `http.headers()` | `SecurityHeadersFilter` / `SecurityHeadersConfig` | OWASP response headers by default |
+| Spring Authorization Server | `AuthorizationServer` + `AuthorizationServerEndpoints` / `pyfly.security.oauth2.authorization-server.*` | Token issuance + OAuth2/OIDC endpoints |
+| `authorization_code` + PKCE / OIDC `id_token` | `AuthorizationServer` (mandatory PKCE S256, single-use code, `openid` id_token) | OAuth 2.1 code flow |
+| `refresh_token` rotation | `AuthorizationServer` refresh grant (rotation + family reuse detection) | Revoke-on-reuse |
+| `/oauth2/jwks` + RS/ES/PS signing | `AuthorizationServer.jwks()` / `algorithm=RS256\|ES256\|PS256\|…` | Asymmetric signing + key publication |
+| Introspection (RFC 7662) / revocation (RFC 7009) | `POST /oauth2/introspect` / `POST /oauth2/revoke` | Client-authenticated |
+| Dynamic client registration | `POST /oauth2/register` / `register_client()` | Programmatic client onboarding |
+| PAR (RFC 9126) / JAR (RFC 9101) | `POST /oauth2/par` / `request=` + `verify_request_object()` | Pushed / signed authorization requests |
+| Authorization Server Metadata / OIDC discovery | `/.well-known/oauth-authorization-server` / `/.well-known/openid-configuration` | Endpoint + capability discovery |
+| RFC 9207 `iss` mix-up defense | `OAuth2LoginHandler` iss check / `ClientRegistration(require_iss=…)` | Validated when present; required when opted in |
+| `OpaqueTokenIntrospector` | `OpaqueTokenIntrospector` | RFC 7662 opaque-token validation |
+| DPoP (RFC 9449) / mTLS (RFC 8705) | `DPoPProofValidator` / `confirm_dpop_binding` / `confirm_mtls_binding` + `pyfly.security.oauth2.resource-server.enforce-sender-constraints` | Sender-constrained tokens via `cnf` |
| Jackson `ObjectMapper` | `PyFlyJsonSerializer` + `pyfly.web.json.*` | Global JSON config |
| Jackson serializer/module | `JsonSerializers.register(...)` | Non-Pydantic type encoders |
| `@JsonNaming` (camelCase) | `CamelModel` | Opt-in camelCase model base |
diff --git a/mkdocs.yml b/mkdocs.yml
index 0b609a3a..2c2dfc27 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -159,6 +159,7 @@ nav:
- Plugins: modules/plugins.md
- Security & Identity:
- Security: modules/security.md
+ - OAuth 2.1 & OIDC: modules/oauth2.md
- Identity Provider (IDP): modules/idp.md
- Integrations:
- Content Management (ECM): modules/ecm.md
diff --git a/pyproject.toml b/pyproject.toml
index f9badac7..09291940 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -7,7 +7,7 @@ name = "pyfly"
# CalVer YY.MM.PATCH — package metadata uses PEP 440 normalized form (26.5.4);
# git tag, GitHub release and human-readable display use leading-zero form
# (v26.05.04) to match the Java/.NET/Go siblings.
-version = "26.6.113"
+version = "26.6.114"
description = "The official Python implementation of the Firefly Framework — DI, CQRS, EDA, hexagonal architecture, and more."
readme = "README.md"
license = "Apache-2.0"
@@ -50,6 +50,9 @@ data-relational = [
testing = [
"jsonpath-ng>=1.8.0",
]
+argon2 = [
+ "argon2-cffi>=23.1.0", # Argon2PasswordEncoder (OWASP-preferred password hashing)
+]
testcontainers = [
"testcontainers>=4.0.0",
"pika>=1.3.0", # testcontainers' RabbitMqContainer imports pika for its readiness probe
@@ -199,6 +202,9 @@ server = "pyfly.server.auto_configuration:ServerAutoConfiguration"
event-loop = "pyfly.server.auto_configuration:EventLoopAutoConfiguration"
security-jwt = "pyfly.security.auto_configuration:JwtAutoConfiguration"
security-password = "pyfly.security.auto_configuration:PasswordEncoderAutoConfiguration"
+security-http-basic = "pyfly.security.auto_configuration:HttpBasicAutoConfiguration"
+security-form-login = "pyfly.security.auto_configuration:FormLoginAutoConfiguration"
+security-logout = "pyfly.security.auto_configuration:LogoutAutoConfiguration"
oauth2-resource-server = "pyfly.security.auto_configuration:OAuth2ResourceServerAutoConfiguration"
oauth2-authorization-server = "pyfly.security.auto_configuration:OAuth2AuthorizationServerAutoConfiguration"
oauth2-client = "pyfly.security.auto_configuration:OAuth2ClientAutoConfiguration"
diff --git a/src/pyfly/__init__.py b/src/pyfly/__init__.py
index 50d2c7d5..0d2dde67 100644
--- a/src/pyfly/__init__.py
+++ b/src/pyfly/__init__.py
@@ -13,4 +13,4 @@
# limitations under the License.
"""PyFly — Enterprise Python Framework."""
-__version__ = "26.06.113"
+__version__ = "26.06.114"
diff --git a/src/pyfly/idp/adapters/__init__.py b/src/pyfly/idp/adapters/__init__.py
index 264175c7..7a07bf26 100644
--- a/src/pyfly/idp/adapters/__init__.py
+++ b/src/pyfly/idp/adapters/__init__.py
@@ -1,3 +1,28 @@
# Copyright 2026 Firefly Software Foundation.
# Licensed under the Apache License, Version 2.0.
"""Concrete IDP adapters."""
+
+from __future__ import annotations
+
+from pyfly.kernel.exceptions import SecurityException
+
+
+def _require_password_grant_optin(allowed: bool, provider: str) -> None:
+ """Refuse the Resource Owner Password Credentials (ROPC) grant unless opted in.
+
+ The ``grant_type=password`` flow (forwarding raw user credentials to an external
+ IdP) is removed by OAuth 2.1 and discouraged by RFC 9700 §2.4 — it cannot carry
+ MFA/step-up, defeats federation, and trains users to enter credentials into the
+ client. It is disabled by default; enable per-adapter with
+ ``allow_password_grant=True`` (config: ``pyfly.idp.allow-password-grant=true``)
+ only for a legacy integration with no migration path. Prefer the
+ authorization_code + PKCE login flow instead.
+ """
+ if not allowed:
+ raise SecurityException(
+ f"The '{provider}' resource-owner-password (ROPC) login flow is disabled. "
+ "It is removed by OAuth 2.1 / discouraged by RFC 9700 §2.4. Use the "
+ "authorization_code + PKCE flow, or, only for a legacy integration, set "
+ "'pyfly.idp.allow-password-grant=true' (or allow_password_grant=True).",
+ code="ROPC_DISABLED",
+ )
diff --git a/src/pyfly/idp/adapters/aws_cognito.py b/src/pyfly/idp/adapters/aws_cognito.py
index 87ba5ad5..25a078fa 100644
--- a/src/pyfly/idp/adapters/aws_cognito.py
+++ b/src/pyfly/idp/adapters/aws_cognito.py
@@ -8,6 +8,7 @@
import logging
from typing import Any
+from pyfly.idp.adapters import _require_password_grant_optin
from pyfly.idp.models import (
AuthResult,
IdpRole,
@@ -40,12 +41,14 @@ def __init__(
region: str,
client_secret: str | None = None,
client: Any | None = None,
+ allow_password_grant: bool = False,
) -> None:
self._user_pool_id = user_pool_id
self._client_id = client_id
self._region = region
self._client_secret = client_secret
self._client = client
+ self._allow_password_grant = allow_password_grant
def _secret_hash(self, username: str) -> str | None:
"""Cognito SECRET_HASH = Base64(HMAC-SHA256(secret, username + client_id)).
@@ -148,6 +151,7 @@ async def list_users(self, *, limit: int = 100) -> list[IdpUser]:
return [_from_cognito(u) for u in data.get("Users", [])]
async def login(self, request: LoginRequest) -> AuthResult:
+ _require_password_grant_optin(self._allow_password_grant, "aws-cognito")
client = self._ensure_client()
auth_params = {"USERNAME": request.username, "PASSWORD": request.password}
secret_hash = self._secret_hash(request.username)
diff --git a/src/pyfly/idp/adapters/azure_ad.py b/src/pyfly/idp/adapters/azure_ad.py
index 81bf5a77..ea9ef5b4 100644
--- a/src/pyfly/idp/adapters/azure_ad.py
+++ b/src/pyfly/idp/adapters/azure_ad.py
@@ -7,6 +7,7 @@
import logging
from typing import Any
+from pyfly.idp.adapters import _require_password_grant_optin
from pyfly.idp.models import (
AuthResult,
IdpRole,
@@ -39,11 +40,13 @@ def __init__(
client_id: str,
client_secret: str,
scope: str = "https://graph.microsoft.com/.default",
+ allow_password_grant: bool = False,
) -> None:
self._tenant_id = tenant_id
self._client_id = client_id
self._client_secret = client_secret
self._scope = scope
+ self._allow_password_grant = allow_password_grant
self._app_token: str | None = None
@property
@@ -139,6 +142,7 @@ async def list_users(self, *, limit: int = 100) -> list[IdpUser]:
return [_from_aad(u) for u in resp.json().get("value", [])]
async def login(self, request: LoginRequest) -> AuthResult:
+ _require_password_grant_optin(self._allow_password_grant, "azure-ad")
async with await self._client() as client:
resp = await client.post(
self._token_url,
diff --git a/src/pyfly/idp/adapters/keycloak.py b/src/pyfly/idp/adapters/keycloak.py
index 2325baea..288acdf5 100644
--- a/src/pyfly/idp/adapters/keycloak.py
+++ b/src/pyfly/idp/adapters/keycloak.py
@@ -15,6 +15,7 @@
import logging
from typing import Any
+from pyfly.idp.adapters import _require_password_grant_optin
from pyfly.idp.models import (
AuthResult,
IdpRole,
@@ -49,12 +50,14 @@ def __init__(
client_id: str,
client_secret: str,
verify_ssl: bool = True,
+ allow_password_grant: bool = False,
) -> None:
self._base_url = base_url.rstrip("/")
self._realm = realm
self._client_id = client_id
self._client_secret = client_secret
self._verify = verify_ssl
+ self._allow_password_grant = allow_password_grant
self._admin_token: str | None = None
self._admin_token_expiry: float = 0.0 # monotonic deadline
@@ -171,6 +174,7 @@ async def list_users(self, *, limit: int = 100) -> list[IdpUser]:
return [_from_kc(u) for u in resp.json()]
async def login(self, request: LoginRequest) -> AuthResult:
+ _require_password_grant_optin(self._allow_password_grant, "keycloak")
async with await self._client() as client:
resp = await client.post(
self._token_url,
diff --git a/src/pyfly/idp/auto_configuration.py b/src/pyfly/idp/auto_configuration.py
index 642c1c76..670b9d32 100644
--- a/src/pyfly/idp/auto_configuration.py
+++ b/src/pyfly/idp/auto_configuration.py
@@ -27,6 +27,14 @@ class IdpAutoConfiguration:
@bean
def idp_adapter(self, config: Config) -> IdpAdapter:
provider = str(config.get("pyfly.idp.provider", "internal-db")).lower()
+ # ROPC (grant_type=password) against an external IdP is removed by OAuth 2.1
+ # and discouraged by RFC 9700 §2.4; it is off unless explicitly opted in.
+ allow_ropc = str(config.get("pyfly.idp.allow-password-grant", False)).strip().lower() in (
+ "1",
+ "true",
+ "yes",
+ "on",
+ )
if provider == "keycloak":
from pyfly.idp.adapters.keycloak import KeycloakIdpAdapter
@@ -36,6 +44,7 @@ def idp_adapter(self, config: Config) -> IdpAdapter:
realm=str(config.get("pyfly.idp.keycloak.realm", "")),
client_id=str(config.get("pyfly.idp.keycloak.client-id", "")),
client_secret=str(config.get("pyfly.idp.keycloak.client-secret", "")),
+ allow_password_grant=allow_ropc,
)
if provider in ("cognito", "aws-cognito"):
from pyfly.idp.adapters.aws_cognito import AwsCognitoIdpAdapter
@@ -45,6 +54,7 @@ def idp_adapter(self, config: Config) -> IdpAdapter:
client_id=str(config.get("pyfly.idp.cognito.client-id", "")),
region=str(config.get("pyfly.idp.cognito.region", "")),
client_secret=str(config.get("pyfly.idp.cognito.client-secret", "")) or None,
+ allow_password_grant=allow_ropc,
)
if provider in ("azure-ad", "azuread", "entra"):
from pyfly.idp.adapters.azure_ad import AzureAdIdpAdapter
@@ -53,6 +63,7 @@ def idp_adapter(self, config: Config) -> IdpAdapter:
tenant_id=str(config.get("pyfly.idp.azure.tenant-id", "")),
client_id=str(config.get("pyfly.idp.azure.client-id", "")),
client_secret=str(config.get("pyfly.idp.azure.client-secret", "")),
+ allow_password_grant=allow_ropc,
)
from pyfly.idp.adapters.internal_db import InternalDbIdpAdapter
diff --git a/src/pyfly/security/__init__.py b/src/pyfly/security/__init__.py
index 1597f4ed..7c9b8af1 100644
--- a/src/pyfly/security/__init__.py
+++ b/src/pyfly/security/__init__.py
@@ -24,22 +24,33 @@
from pyfly.security.context import SecurityContext
from pyfly.security.decorators import secure
-from pyfly.security.expression import get_role_hierarchy, set_role_hierarchy
+from pyfly.security.expression import (
+ get_permission_evaluator,
+ get_role_hierarchy,
+ set_permission_evaluator,
+ set_role_hierarchy,
+)
from pyfly.security.http_security import AccessRule, AccessRuleType, HttpSecurity, SecurityRule
-from pyfly.security.method_security import post_authorize, pre_authorize
+from pyfly.security.method_security import post_authorize, post_filter, pre_authorize, pre_filter
+from pyfly.security.permission import PermissionEvaluator
from pyfly.security.role_hierarchy import RoleHierarchy
__all__ = [
"AccessRule",
"AccessRuleType",
"HttpSecurity",
+ "PermissionEvaluator",
"RoleHierarchy",
"SecurityContext",
"SecurityRule",
+ "get_permission_evaluator",
"get_role_hierarchy",
"post_authorize",
+ "post_filter",
"pre_authorize",
+ "pre_filter",
"secure",
+ "set_permission_evaluator",
"set_role_hierarchy",
]
@@ -61,8 +72,62 @@
pass
try:
- from pyfly.security.password import BcryptPasswordEncoder, PasswordEncoder
+ from pyfly.security.password import (
+ Argon2PasswordEncoder,
+ BcryptPasswordEncoder,
+ DelegatingPasswordEncoder,
+ PasswordEncoder,
+ Pbkdf2PasswordEncoder,
+ ScryptPasswordEncoder,
+ create_delegating_password_encoder,
+ )
- __all__ += ["BcryptPasswordEncoder", "PasswordEncoder"]
+ __all__ += [
+ "Argon2PasswordEncoder",
+ "BcryptPasswordEncoder",
+ "DelegatingPasswordEncoder",
+ "PasswordEncoder",
+ "Pbkdf2PasswordEncoder",
+ "ScryptPasswordEncoder",
+ "create_delegating_password_encoder",
+ ]
+except ImportError:
+ pass
+
+try:
+ from pyfly.security.user_details import (
+ InMemoryUserDetailsService,
+ UserDetails,
+ UserDetailsService,
+ )
+
+ __all__ += ["InMemoryUserDetailsService", "UserDetails", "UserDetailsService"]
+except ImportError:
+ pass
+
+# AuthenticationProvider/DaoAuthenticationProvider transitively need a
+# PasswordEncoder (bcrypt), so guard the import like the other optional pieces.
+try:
+ from pyfly.security.authentication import (
+ Authentication,
+ AuthenticationException,
+ AuthenticationProvider,
+ BadCredentialsException,
+ DaoAuthenticationProvider,
+ DisabledException,
+ ProviderManager,
+ ProviderNotFoundException,
+ )
+
+ __all__ += [
+ "Authentication",
+ "AuthenticationException",
+ "AuthenticationProvider",
+ "BadCredentialsException",
+ "DaoAuthenticationProvider",
+ "DisabledException",
+ "ProviderManager",
+ "ProviderNotFoundException",
+ ]
except ImportError:
pass
diff --git a/src/pyfly/security/adapters/sql_user_details.py b/src/pyfly/security/adapters/sql_user_details.py
new file mode 100644
index 00000000..28ffbad7
--- /dev/null
+++ b/src/pyfly/security/adapters/sql_user_details.py
@@ -0,0 +1,128 @@
+# Copyright 2026 Firefly Software Foundation.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""SQL table-backed :class:`UserDetailsService` (Spring's ``JdbcUserDetailsManager``).
+
+Durable user/credential storage for HTTP Basic / form login, backed by any
+SQLAlchemy ``AsyncEngine``. Hexagonal: the engine is injected lazily by the
+composition root; no SQLAlchemy import at module scope. The table is created
+lazily and idempotently on first use.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import json
+import re
+from collections.abc import Callable
+from typing import Any
+
+from pyfly.security.user_details import UserDetails
+
+_IDENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
+
+
+class SqlUserDetailsService:
+ """A :class:`UserDetailsService` storing users in a SQL table.
+
+ Columns: ``username`` (PK), ``password_hash``, ``roles`` (JSON), ``permissions``
+ (JSON), ``enabled`` (int). Works on PostgreSQL and SQLite (``ON CONFLICT`` upsert).
+ """
+
+ def __init__(self, engine_factory: Callable[[], Any], *, table: str = "pyfly_users") -> None:
+ if not _IDENT.match(table):
+ raise ValueError(f"Invalid user-store table name: {table!r}")
+ self._engine_factory = engine_factory
+ self._engine: Any = None
+ self._table = table
+ self._ensured = False
+ self._guard = asyncio.Lock()
+
+ def _eng(self) -> Any:
+ if self._engine is None:
+ self._engine = self._engine_factory()
+ return self._engine
+
+ async def _ensure_table(self) -> None:
+ if self._ensured:
+ return
+ from sqlalchemy import text
+
+ async with self._guard:
+ if self._ensured:
+ return
+ async with self._eng().begin() as conn:
+ await conn.execute(
+ text(
+ f"CREATE TABLE IF NOT EXISTS {self._table} ("
+ "username TEXT PRIMARY KEY, "
+ "password_hash TEXT NOT NULL, "
+ "roles TEXT NOT NULL DEFAULT '[]', "
+ "permissions TEXT NOT NULL DEFAULT '[]', "
+ "enabled INTEGER NOT NULL DEFAULT 1)"
+ )
+ )
+ self._ensured = True
+
+ async def load_user_by_username(self, username: str) -> UserDetails | None:
+ from sqlalchemy import text
+
+ await self._ensure_table()
+ async with self._eng().connect() as conn:
+ result = await conn.execute(
+ text(
+ f"SELECT username, password_hash, roles, permissions, enabled "
+ f"FROM {self._table} WHERE username = :u"
+ ),
+ {"u": username},
+ )
+ row = result.first()
+ if row is None:
+ return None
+ return UserDetails(
+ username=row[0],
+ password_hash=row[1],
+ roles=list(json.loads(row[2] or "[]")),
+ permissions=list(json.loads(row[3] or "[]")),
+ enabled=bool(row[4]),
+ )
+
+ async def save(self, user: UserDetails) -> None:
+ """Insert or update *user* (keyed by username)."""
+ from sqlalchemy import text
+
+ await self._ensure_table()
+ async with self._eng().begin() as conn:
+ await conn.execute(
+ text(
+ f"INSERT INTO {self._table} (username, password_hash, roles, permissions, enabled) "
+ "VALUES (:u, :p, :r, :perm, :e) "
+ "ON CONFLICT (username) DO UPDATE SET "
+ "password_hash = excluded.password_hash, roles = excluded.roles, "
+ "permissions = excluded.permissions, enabled = excluded.enabled"
+ ),
+ {
+ "u": user.username,
+ "p": user.password_hash,
+ "r": json.dumps(list(user.roles)),
+ "perm": json.dumps(list(user.permissions)),
+ "e": 1 if user.enabled else 0,
+ },
+ )
+
+ async def delete(self, username: str) -> None:
+ from sqlalchemy import text
+
+ await self._ensure_table()
+ async with self._eng().begin() as conn:
+ await conn.execute(text(f"DELETE FROM {self._table} WHERE username = :u"), {"u": username})
diff --git a/src/pyfly/security/authentication.py b/src/pyfly/security/authentication.py
new file mode 100644
index 00000000..509c3e45
--- /dev/null
+++ b/src/pyfly/security/authentication.py
@@ -0,0 +1,158 @@
+# Copyright 2026 Firefly Software Foundation.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Authentication SPI — Spring's ``AuthenticationManager`` / ``AuthenticationProvider``.
+
+A :class:`ProviderManager` delegates an :class:`Authentication` request to the
+first :class:`AuthenticationProvider` that ``supports`` it. The built-in
+:class:`DaoAuthenticationProvider` checks a username/password against a
+:class:`~pyfly.security.user_details.UserDetailsService` and a
+:class:`~pyfly.security.password.PasswordEncoder`.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Iterable
+from dataclasses import dataclass, field
+from typing import Any, Protocol, runtime_checkable
+
+from pyfly.kernel.exceptions import SecurityException
+from pyfly.security.context import SecurityContext
+from pyfly.security.password import PasswordEncoder
+from pyfly.security.user_details import UserDetailsService
+
+
+class AuthenticationException(SecurityException):
+ """Base class for authentication failures."""
+
+
+class BadCredentialsException(AuthenticationException):
+ """The supplied credentials were invalid (or the principal is unknown)."""
+
+ def __init__(self, message: str = "Bad credentials") -> None:
+ super().__init__(message, code="BAD_CREDENTIALS")
+
+
+class DisabledException(AuthenticationException):
+ """The account exists but is disabled."""
+
+ def __init__(self, message: str = "Account is disabled") -> None:
+ super().__init__(message, code="ACCOUNT_DISABLED")
+
+
+class ProviderNotFoundException(AuthenticationException):
+ """No configured provider could handle the authentication request."""
+
+ def __init__(self, message: str = "No authentication provider for this request") -> None:
+ super().__init__(message, code="PROVIDER_NOT_FOUND")
+
+
+@dataclass
+class Authentication:
+ """An authentication request or result (cf. Spring's ``Authentication``).
+
+ Before authentication: ``principal`` + ``credentials`` are the submitted
+ username/password. After: ``authenticated`` is True, ``authorities`` /
+ ``roles`` / ``permissions`` are populated and ``credentials`` is erased.
+ """
+
+ principal: str
+ credentials: str | None = None
+ authorities: list[str] = field(default_factory=list)
+ authenticated: bool = False
+ roles: list[str] = field(default_factory=list)
+ permissions: list[str] = field(default_factory=list)
+ details: dict[str, Any] = field(default_factory=dict)
+
+ def to_security_context(self) -> SecurityContext:
+ """Build a :class:`SecurityContext` from this (authenticated) result."""
+ return SecurityContext(
+ user_id=self.principal if self.authenticated else None,
+ roles=list(self.roles),
+ permissions=list(self.permissions),
+ )
+
+
+@runtime_checkable
+class AuthenticationProvider(Protocol):
+ """Authenticates an :class:`Authentication` it ``supports``."""
+
+ def supports(self, authentication: Authentication) -> bool: ...
+
+ async def authenticate(self, authentication: Authentication) -> Authentication: ...
+
+
+class DaoAuthenticationProvider:
+ """Authenticates username/password against a UserDetailsService + PasswordEncoder."""
+
+ def __init__(self, user_details_service: UserDetailsService, password_encoder: PasswordEncoder) -> None:
+ self._users = user_details_service
+ self._encoder = password_encoder
+ # A throw-away hash so an unknown user still incurs a verify() — equalising
+ # timing so the endpoint can't be used to enumerate valid usernames.
+ self._dummy_hash = password_encoder.hash("pyfly-dummy-password")
+
+ def supports(self, authentication: Authentication) -> bool:
+ return bool(authentication.principal) and authentication.credentials is not None
+
+ async def authenticate(self, authentication: Authentication) -> Authentication:
+ user = await self._users.load_user_by_username(authentication.principal)
+ credentials = authentication.credentials or ""
+ if user is None:
+ self._encoder.verify(credentials, self._dummy_hash) # constant-time-ish
+ raise BadCredentialsException()
+ if not self._encoder.verify(credentials, user.password_hash):
+ raise BadCredentialsException()
+ if not user.enabled:
+ raise DisabledException()
+ return Authentication(
+ principal=user.username,
+ credentials=None,
+ authenticated=True,
+ roles=list(user.roles),
+ permissions=list(user.permissions),
+ authorities=[*user.roles, *user.permissions],
+ details=dict(authentication.details),
+ )
+
+
+class ProviderManager:
+ """An :class:`AuthenticationManager` that consults providers in order."""
+
+ def __init__(self, *providers: AuthenticationProvider) -> None:
+ self._providers: list[AuthenticationProvider] = list(providers)
+
+ @classmethod
+ def of(cls, providers: Iterable[AuthenticationProvider]) -> ProviderManager:
+ return cls(*providers)
+
+ async def authenticate(self, authentication: Authentication) -> Authentication:
+ last_error: AuthenticationException | None = None
+ supported = False
+ for provider in self._providers:
+ if not provider.supports(authentication):
+ continue
+ supported = True
+ try:
+ result = await provider.authenticate(authentication)
+ except AuthenticationException as exc:
+ last_error = exc
+ continue
+ if result.authenticated:
+ result.credentials = None # erase credentials on success
+ return result
+ if last_error is not None:
+ raise last_error
+ if not supported:
+ raise ProviderNotFoundException()
+ raise BadCredentialsException()
diff --git a/src/pyfly/security/auto_configuration.py b/src/pyfly/security/auto_configuration.py
index 07f3727f..9c920a64 100644
--- a/src/pyfly/security/auto_configuration.py
+++ b/src/pyfly/security/auto_configuration.py
@@ -22,9 +22,10 @@
JWTService = object # type: ignore[misc,assignment]
try:
- from pyfly.security.password import BcryptPasswordEncoder
+ from pyfly.security.password import BcryptPasswordEncoder, DelegatingPasswordEncoder
except ImportError:
BcryptPasswordEncoder = object # type: ignore[misc,assignment]
+ DelegatingPasswordEncoder = object # type: ignore[misc,assignment]
try:
from pyfly.security.oauth2.resource_server import (
@@ -86,6 +87,61 @@
conditional_on_property,
)
from pyfly.core.config import Config
+from pyfly.kernel.exceptions import SecurityException
+
+# The built-in placeholder secret shipped in defaults. Signing tokens with it
+# would let anyone who knows the (public) framework default forge tokens, so the
+# composition root refuses to start when it is left in place.
+_PLACEHOLDER_SECRET = "change-me-in-production"
+# Minimum HMAC key length for the HS family (RFC 7518 §3.2: a key of the same
+# size as the hash output — 256 bits / 32 bytes — for HS256).
+_MIN_HS_SECRET_BYTES = 32
+
+
+def _resolve_signing_secret(config: Config, key: str, algorithm: str) -> str:
+ """Read a token-signing secret from *key* and refuse insecure values.
+
+ Raises:
+ SecurityException: if the secret is unset (the built-in placeholder) or,
+ for an HMAC (``HS*``) algorithm, shorter than 32 bytes.
+ """
+ secret = str(config.get(key, _PLACEHOLDER_SECRET))
+ if secret == _PLACEHOLDER_SECRET:
+ raise SecurityException(
+ f"Refusing to start: '{key}' is unset, so the built-in placeholder secret "
+ f"would be used to sign tokens. Set '{key}' to a strong, randomly-generated "
+ f'value (e.g. `python -c "import secrets; print(secrets.token_urlsafe(48))"`).',
+ code="INSECURE_SIGNING_SECRET",
+ )
+ if algorithm.upper().startswith("HS") and len(secret.encode("utf-8")) < _MIN_HS_SECRET_BYTES:
+ raise SecurityException(
+ f"Refusing to start: '{key}' must be at least {_MIN_HS_SECRET_BYTES} bytes for "
+ f"{algorithm} (RFC 7518 §3.2); got {len(secret.encode('utf-8'))} bytes.",
+ code="WEAK_SIGNING_SECRET",
+ )
+ return secret
+
+
+def _audience(config: Config, key: str) -> str | list[str] | None:
+ """Read a comma-separated / list audience value (single value collapsed to a
+ string), or ``None`` when unset."""
+ raw = config.get(key)
+ if raw is None:
+ return None
+ if isinstance(raw, (list, tuple)):
+ values = [str(a).strip() for a in raw if str(a).strip()]
+ else:
+ values = [a.strip() for a in str(raw).split(",") if a.strip()]
+ if not values:
+ return None
+ return values[0] if len(values) == 1 else values
+
+
+def _as_bool(value: Any) -> bool:
+ """Coerce a config value (bool or string like ``"true"``/``"false"``) to bool."""
+ if isinstance(value, bool):
+ return value
+ return str(value).strip().lower() in ("1", "true", "yes", "on")
def _exclude_patterns(config: Config, key: str) -> Sequence[str]:
@@ -106,8 +162,16 @@ class JwtAutoConfiguration:
@bean
def jwt_service(self, config: Config) -> JWTService:
- secret = str(config.get("pyfly.security.jwt.secret", "change-me-in-production"))
algorithm = str(config.get("pyfly.security.jwt.algorithm", "HS256"))
+ # The symmetric secret is only enforced when the symmetric JWT filter is
+ # actually serving requests. A resource-server-only app (the recommended
+ # setup) enables ``pyfly.security.enabled`` for the JWKS validator and never
+ # uses this signer, so it must not be forced to invent a symmetric secret.
+ filter_enabled = str(config.get("pyfly.security.jwt.filter.enabled", "false")).lower() == "true"
+ if filter_enabled:
+ secret = _resolve_signing_secret(config, "pyfly.security.jwt.secret", algorithm)
+ else:
+ secret = str(config.get("pyfly.security.jwt.secret", _PLACEHOLDER_SECRET))
return JWTService(secret=secret, algorithm=algorithm)
@bean
@@ -135,6 +199,146 @@ def password_encoder(self, config: Config) -> BcryptPasswordEncoder:
rounds = int(config.get("pyfly.security.password.bcrypt-rounds", 12))
return BcryptPasswordEncoder(rounds=rounds)
+ @bean
+ @conditional_on_property("pyfly.security.password.delegating.enabled", having_value="true")
+ def delegating_password_encoder(self, config: Config) -> DelegatingPasswordEncoder:
+ # Opt-in Spring-style {id}-prefixed encoder (bcrypt default, recognises
+ # {pbkdf2}/{scrypt}/{argon2}) enabling on-login algorithm migration.
+ from pyfly.security.password import create_delegating_password_encoder
+
+ rounds = int(config.get("pyfly.security.password.bcrypt-rounds", 12))
+ return create_delegating_password_encoder(bcrypt_rounds=rounds)
+
+
+# ---------------------------------------------------------------------------
+# HTTP Basic authentication
+# ---------------------------------------------------------------------------
+
+
+def _csv_or_list(value: Any) -> list[str]:
+ """Parse a comma-separated string or a list into a trimmed string list."""
+ if value is None:
+ return []
+ if isinstance(value, (list, tuple)):
+ return [str(v).strip() for v in value if str(v).strip()]
+ return [s.strip() for s in str(value).split(",") if s.strip()]
+
+
+def _users_from_config(config: Config, key: str) -> list[Any]:
+ """Build :class:`UserDetails` from a config map of pre-hashed users at *key*."""
+ from pyfly.security.user_details import UserDetails
+
+ raw = config.get(key, {})
+ users: list[Any] = []
+ if isinstance(raw, dict):
+ for username, props in raw.items():
+ if not isinstance(props, dict):
+ continue
+ users.append(
+ UserDetails(
+ username=str(username),
+ password_hash=str(props.get("password-hash", "")),
+ roles=_csv_or_list(props.get("roles")),
+ permissions=_csv_or_list(props.get("permissions")),
+ enabled=_as_bool(props.get("enabled", True)),
+ )
+ )
+ return users
+
+
+@auto_configuration
+@conditional_on_property("pyfly.security.http-basic.enabled", having_value="true")
+@conditional_on_class("starlette")
+@conditional_on_class("bcrypt")
+class HttpBasicAutoConfiguration:
+ """Auto-configures HTTP Basic authentication from config (opt-in).
+
+ Users are declared (with **pre-hashed** bcrypt passwords) under
+ ``pyfly.security.http-basic.users``::
+
+ pyfly:
+ security:
+ http-basic:
+ enabled: true
+ realm: "PyFly"
+ error-mode: "401" # or "anonymous"
+ users:
+ alice:
+ password-hash: "$2b$12$..." # never plaintext
+ roles: "ADMIN,USER"
+
+ Apps needing a dynamic user store register their own
+ :class:`HttpBasicAuthenticationFilter` (a ``WebFilter`` bean) instead.
+ """
+
+ @bean
+ def http_basic_filter(self, config: Config) -> WebFilter:
+ from pyfly.security.password import BcryptPasswordEncoder
+ from pyfly.security.user_details import InMemoryUserDetailsService
+ from pyfly.web.adapters.starlette.filters.http_basic_filter import HttpBasicAuthenticationFilter
+
+ users = _users_from_config(config, "pyfly.security.http-basic.users")
+ rounds = int(config.get("pyfly.security.password.bcrypt-rounds", 12))
+ return HttpBasicAuthenticationFilter(
+ InMemoryUserDetailsService(*users),
+ BcryptPasswordEncoder(rounds=rounds),
+ realm=str(config.get("pyfly.security.http-basic.realm", "Realm")),
+ error_mode=str(config.get("pyfly.security.http-basic.error-mode", "anonymous")),
+ )
+
+
+@auto_configuration
+@conditional_on_property("pyfly.security.form-login.enabled", having_value="true")
+@conditional_on_class("starlette")
+@conditional_on_class("bcrypt")
+class FormLoginAutoConfiguration:
+ """Auto-configures form login from config (opt-in).
+
+ Declares users (pre-hashed) under ``pyfly.security.form-login.users`` and tunes
+ URLs/params under ``pyfly.security.form-login.*``. Apps with a dynamic user
+ store register their own ``FormLoginFilter`` ``WebFilter`` bean instead.
+ """
+
+ @bean
+ def form_login_filter(self, config: Config) -> WebFilter:
+ from pyfly.security.authentication import DaoAuthenticationProvider, ProviderManager
+ from pyfly.security.password import BcryptPasswordEncoder
+ from pyfly.security.user_details import InMemoryUserDetailsService
+ from pyfly.web.adapters.starlette.filters.form_login_filter import FormLoginFilter
+
+ users = _users_from_config(config, "pyfly.security.form-login.users")
+ rounds = int(config.get("pyfly.security.password.bcrypt-rounds", 12))
+ manager = ProviderManager(
+ DaoAuthenticationProvider(InMemoryUserDetailsService(*users), BcryptPasswordEncoder(rounds=rounds))
+ )
+ return FormLoginFilter(
+ manager,
+ login_url=str(config.get("pyfly.security.form-login.login-url", "/login")),
+ username_param=str(config.get("pyfly.security.form-login.username-param", "username")),
+ password_param=str(config.get("pyfly.security.form-login.password-param", "password")),
+ success_url=str(config.get("pyfly.security.form-login.success-url", "/")),
+ failure_url=str(config.get("pyfly.security.form-login.failure-url", "/login?error")),
+ use_redirect=_as_bool(config.get("pyfly.security.form-login.use-redirect", True)),
+ )
+
+
+@auto_configuration
+@conditional_on_property("pyfly.security.logout.enabled", having_value="true")
+@conditional_on_class("starlette")
+class LogoutAutoConfiguration:
+ """Auto-configures a generic logout filter (opt-in) from ``pyfly.security.logout.*``."""
+
+ @bean
+ def logout_filter(self, config: Config) -> WebFilter:
+ from pyfly.web.adapters.starlette.filters.logout_filter import LogoutFilter
+
+ return LogoutFilter(
+ logout_url=str(config.get("pyfly.security.logout.logout-url", "/logout")),
+ logout_success_url=str(config.get("pyfly.security.logout.success-url", "/login?logout")),
+ delete_cookies=_csv_or_list(config.get("pyfly.security.logout.delete-cookies")),
+ use_redirect=_as_bool(config.get("pyfly.security.logout.use-redirect", True)),
+ )
+
# ---------------------------------------------------------------------------
# OAuth2 Resource Server
@@ -200,6 +404,8 @@ def oauth2_resource_server_filter(self, token_validator: JWKSTokenValidator, con
token_validator=token_validator,
exclude_patterns=props.exclude_pattern_list(),
error_mode=props.authenticate_error_mode,
+ enforce_sender_constraints=props.enforce_sender_constraints,
+ mtls_cert_header=props.mtls_cert_header,
)
@@ -227,7 +433,7 @@ def authorization_server(
client_registration_repository: InMemoryClientRegistrationRepository,
container: Container,
) -> AuthorizationServer:
- secret = str(config.get("pyfly.security.oauth2.authorization-server.secret", "change-me-in-production"))
+ secret = _resolve_signing_secret(config, "pyfly.security.oauth2.authorization-server.secret", "HS256")
issuer = config.get("pyfly.security.oauth2.authorization-server.issuer")
access_ttl = int(config.get("pyfly.security.oauth2.authorization-server.access-token-ttl", 3600))
refresh_ttl = int(config.get("pyfly.security.oauth2.authorization-server.refresh-token-ttl", 86400))
@@ -239,6 +445,7 @@ def authorization_server(
access_token_ttl=access_ttl,
refresh_token_ttl=refresh_ttl,
issuer=str(issuer) if issuer is not None else None,
+ audience=_audience(config, "pyfly.security.oauth2.authorization-server.audience"),
)
def _build_token_store(self, config: Config, container: Container, refresh_ttl: int) -> Any:
@@ -335,6 +542,12 @@ def client_registration_repository(self, config: Config) -> InMemoryClientRegist
jwks_uri=str(props.get("jwks-uri", "")),
issuer_uri=str(props.get("issuer-uri", "")),
provider_name=str(props.get("provider-name", "")),
+ # PKCE on by default (RFC 9700 / OAuth 2.1); opt out per
+ # registration with ``use-pkce: false``.
+ use_pkce=_as_bool(props.get("use-pkce", True)),
+ # RFC 9207 iss enforcement (opt-in; iss is validated when
+ # present regardless).
+ require_iss=_as_bool(props.get("require-iss", False)),
)
)
diff --git a/src/pyfly/security/expression.py b/src/pyfly/security/expression.py
index 02a71ab1..4cc93468 100644
--- a/src/pyfly/security/expression.py
+++ b/src/pyfly/security/expression.py
@@ -36,6 +36,7 @@
from pyfly.kernel.exceptions import SecurityException
from pyfly.security.context import SecurityContext
+from pyfly.security.permission import PermissionEvaluator
from pyfly.security.role_hierarchy import RoleHierarchy
_PARAM_RE = re.compile(r"#(\w+)")
@@ -45,6 +46,10 @@
# RoleHierarchy bean). Configure once at startup via set_role_hierarchy().
_active_hierarchy: RoleHierarchy | None = None
+# Process-wide PermissionEvaluator backing hasPermission(target, perm). When unset,
+# hasPermission falls back to a flat permission check on the SecurityContext.
+_active_permission_evaluator: PermissionEvaluator | None = None
+
def set_role_hierarchy(hierarchy: RoleHierarchy | None) -> None:
"""Install the role hierarchy used by method-security role checks (``None`` disables)."""
@@ -57,6 +62,39 @@ def get_role_hierarchy() -> RoleHierarchy | None:
return _active_hierarchy
+def set_permission_evaluator(evaluator: PermissionEvaluator | None) -> None:
+ """Install the PermissionEvaluator used by ``hasPermission`` (``None`` disables)."""
+ global _active_permission_evaluator
+ _active_permission_evaluator = evaluator
+
+
+def get_permission_evaluator() -> PermissionEvaluator | None:
+ """Return the currently installed PermissionEvaluator, if any."""
+ return _active_permission_evaluator
+
+
+def _eval_permission(ctx: SecurityContext, parts: tuple[Any, ...]) -> bool:
+ """Resolve a ``hasPermission(...)`` call against the evaluator or the context.
+
+ Argument shapes (Spring parity):
+ * ``(permission,)`` — flat permission check
+ * ``(target, permission)`` — domain-object permission
+ * ``(target_id, target_type, perm)`` — identifier + type permission
+ """
+ if not parts:
+ return False
+ evaluator = _active_permission_evaluator
+ if evaluator is None:
+ # No ACL evaluator: fall back to the principal's flat permissions.
+ return ctx.has_permission(str(parts[-1]))
+ if len(parts) == 1:
+ return evaluator.has_permission(ctx, None, str(parts[0]))
+ if len(parts) == 2:
+ return evaluator.has_permission(ctx, parts[0], str(parts[1]))
+ target_id, target_type, permission = parts[-3], parts[-2], parts[-1]
+ return evaluator.has_permission(ctx, target_id, str(permission), target_type=str(target_type))
+
+
def _effective_roles(ctx: SecurityContext) -> set[str]:
"""The principal's roles, expanded through the active hierarchy when one is set."""
if _active_hierarchy is None:
@@ -102,7 +140,9 @@ def _has_authority(ctx: SecurityContext, authority: Any) -> bool:
return _has_role(ctx, name) or ctx.has_permission(name)
-def _build_namespace(ctx: SecurityContext, args: dict[str, Any] | None, return_object: Any) -> dict[str, Any]:
+def _build_namespace(
+ ctx: SecurityContext, args: dict[str, Any] | None, return_object: Any, filter_object: Any = None
+) -> dict[str, Any]:
namespace: dict[str, Any] = {
"principal": ctx,
"authentication": ctx,
@@ -120,10 +160,11 @@ def _build_namespace(ctx: SecurityContext, args: dict[str, Any] | None, return_o
"hasAnyRole": _BoolFn(lambda *roles: any(_has_role(ctx, r) for r in roles)),
"hasAuthority": _BoolFn(lambda authority: _has_authority(ctx, authority)),
"hasAnyAuthority": _BoolFn(lambda *auths: any(_has_authority(ctx, a) for a in auths)),
- # 1-arg hasPermission(perm) or 2-arg hasPermission(target, perm) — the last
- # argument is the permission (target-based ACLs are not modelled).
- "hasPermission": _BoolFn(lambda *parts: ctx.has_permission(str(parts[-1]))),
+ # hasPermission(perm) / (target, perm) / (id, type, perm) — dispatched to the
+ # installed PermissionEvaluator, or a flat context check when none is set.
+ "hasPermission": _BoolFn(lambda *parts: _eval_permission(ctx, parts)),
"returnObject": return_object,
+ "filterObject": filter_object,
}
for key, value in (args or {}).items():
namespace[_PARAM_PREFIX + key] = value
@@ -188,11 +229,15 @@ def evaluate_security_expression(
*,
args: dict[str, Any] | None = None,
return_object: Any = None,
+ filter_object: Any = None,
) -> bool:
- """Evaluate a method-security expression; returns the boolean decision."""
+ """Evaluate a method-security expression; returns the boolean decision.
+
+ *filter_object* binds ``filterObject`` for ``@pre_filter`` / ``@post_filter``.
+ """
translated = _PARAM_RE.sub(lambda m: _PARAM_PREFIX + m.group(1), expression.strip())
try:
tree = ast.parse(translated, mode="eval")
except SyntaxError as exc:
raise SecurityException(f"Invalid security expression syntax: {exc}", code="INVALID_EXPRESSION") from exc
- return bool(_eval(tree, _build_namespace(ctx, args, return_object)))
+ return bool(_eval(tree, _build_namespace(ctx, args, return_object, filter_object)))
diff --git a/src/pyfly/security/http_security.py b/src/pyfly/security/http_security.py
index 40a467ca..95a3f9c8 100644
--- a/src/pyfly/security/http_security.py
+++ b/src/pyfly/security/http_security.py
@@ -78,10 +78,22 @@ class SecurityRule:
patterns: Glob patterns (fnmatch-style) to match against the
request path. An empty list means "any request".
rule: The access rule to enforce when a pattern matches.
+ methods: Upper-case HTTP methods this rule applies to. An empty list
+ (the default) matches any method.
"""
patterns: list[str]
rule: AccessRule
+ methods: list[str] = field(default_factory=list)
+
+
+def _normalize_methods(methods: str | list[str] | tuple[str, ...] | None) -> list[str]:
+ """Coerce a method spec (str / list / None) into a list of upper-case methods."""
+ if methods is None:
+ return []
+ if isinstance(methods, str):
+ return [methods.upper()]
+ return [m.upper() for m in methods]
# ---------------------------------------------------------------------------
@@ -92,40 +104,46 @@ class SecurityRule:
class _RequestMatcherBuilder:
"""Intermediate builder returned by ``authorize_requests().request_matchers(...)``."""
- def __init__(self, registry: _AuthorizeRequestsBuilder, patterns: list[str]) -> None:
+ def __init__(
+ self,
+ registry: _AuthorizeRequestsBuilder,
+ patterns: list[str],
+ methods: list[str] | None = None,
+ ) -> None:
self._registry = registry
self._patterns = patterns
+ self._methods = methods or []
# -- terminal access-rule methods --
def permit_all(self) -> _AuthorizeRequestsBuilder:
"""Allow all requests matching the current patterns."""
- self._registry._add_rule(self._patterns, AccessRule(AccessRuleType.PERMIT_ALL))
+ self._registry._add_rule(self._patterns, AccessRule(AccessRuleType.PERMIT_ALL), self._methods)
return self._registry
def deny_all(self) -> _AuthorizeRequestsBuilder:
"""Deny all requests matching the current patterns."""
- self._registry._add_rule(self._patterns, AccessRule(AccessRuleType.DENY_ALL))
+ self._registry._add_rule(self._patterns, AccessRule(AccessRuleType.DENY_ALL), self._methods)
return self._registry
def authenticated(self) -> _AuthorizeRequestsBuilder:
"""Require an authenticated user for the current patterns."""
- self._registry._add_rule(self._patterns, AccessRule(AccessRuleType.AUTHENTICATED))
+ self._registry._add_rule(self._patterns, AccessRule(AccessRuleType.AUTHENTICATED), self._methods)
return self._registry
def has_role(self, role: str) -> _AuthorizeRequestsBuilder:
"""Require the user to have *role* for the current patterns."""
- self._registry._add_rule(self._patterns, AccessRule(AccessRuleType.HAS_ROLE, role))
+ self._registry._add_rule(self._patterns, AccessRule(AccessRuleType.HAS_ROLE, role), self._methods)
return self._registry
def has_any_role(self, roles: list[str]) -> _AuthorizeRequestsBuilder:
"""Require the user to have at least one of *roles* for the current patterns."""
- self._registry._add_rule(self._patterns, AccessRule(AccessRuleType.HAS_ANY_ROLE, list(roles)))
+ self._registry._add_rule(self._patterns, AccessRule(AccessRuleType.HAS_ANY_ROLE, list(roles)), self._methods)
return self._registry
def has_permission(self, permission: str) -> _AuthorizeRequestsBuilder:
"""Require the user to have *permission* for the current patterns."""
- self._registry._add_rule(self._patterns, AccessRule(AccessRuleType.HAS_PERMISSION, permission))
+ self._registry._add_rule(self._patterns, AccessRule(AccessRuleType.HAS_PERMISSION, permission), self._methods)
return self._registry
@@ -138,26 +156,32 @@ class _AuthorizeRequestsBuilder:
def __init__(self, security: HttpSecurity) -> None:
self._security = security
- def request_matchers(self, *patterns: str) -> _RequestMatcherBuilder:
+ def request_matchers(
+ self, *patterns: str, methods: str | list[str] | tuple[str, ...] | None = None
+ ) -> _RequestMatcherBuilder:
"""Begin a rule for one or more URL glob patterns.
Args:
*patterns: fnmatch-style glob patterns (e.g. ``"/api/admin/**"``).
+ methods: Optional HTTP method(s) the rule applies to (e.g. ``"POST"``
+ or ``["PUT", "DELETE"]``). When omitted, the rule matches any
+ method — mirroring Spring's ``requestMatchers(HttpMethod.X, ...)``.
Returns:
A :class:`_RequestMatcherBuilder` to set the access rule.
"""
- return _RequestMatcherBuilder(self, list(patterns))
+ return _RequestMatcherBuilder(self, list(patterns), _normalize_methods(methods))
- def any_request(self) -> _RequestMatcherBuilder:
+ def any_request(self, *, methods: str | list[str] | tuple[str, ...] | None = None) -> _RequestMatcherBuilder:
"""Begin a catch-all rule that matches any request path.
- This should be the **last** rule in the chain.
+ This should be the **last** rule in the chain. An optional ``methods``
+ restricts the catch-all to specific HTTP methods.
"""
- return _RequestMatcherBuilder(self, [])
+ return _RequestMatcherBuilder(self, [], _normalize_methods(methods))
- def _add_rule(self, patterns: list[str], rule: AccessRule) -> None:
- self._security._rules.append(SecurityRule(patterns=patterns, rule=rule))
+ def _add_rule(self, patterns: list[str], rule: AccessRule, methods: list[str] | None = None) -> None:
+ self._security._rules.append(SecurityRule(patterns=patterns, rule=rule, methods=methods or []))
# ---------------------------------------------------------------------------
diff --git a/src/pyfly/security/method_security.py b/src/pyfly/security/method_security.py
index 536e9adf..8c9e15f8 100644
--- a/src/pyfly/security/method_security.py
+++ b/src/pyfly/security/method_security.py
@@ -76,6 +76,104 @@ def _check_expression(
)
+_COLLECTION_TYPES = (list, tuple, set)
+
+
+def _filter_collection(expression: str, collection: Any, args: dict[str, Any]) -> Any:
+ """Return *collection* with only the elements for which *expression* (bound to
+ ``filterObject``) is True, preserving the collection's concrete type."""
+ ctx = _get_security_context()
+ kept = [item for item in collection if evaluate_security_expression(expression, ctx, args=args, filter_object=item)]
+ return type(collection)(kept)
+
+
+def _first_collection_param(arguments: dict[str, Any]) -> str | None:
+ """Name of the first argument (skipping ``self``/``cls``) holding a collection."""
+ for name, value in arguments.items():
+ if name in ("self", "cls"):
+ continue
+ if isinstance(value, _COLLECTION_TYPES):
+ return name
+ return None
+
+
+def pre_filter(expression: str, filter_target: str | None = None) -> Callable[[F], F]:
+ """Filter a collection *argument* before the method runs (Spring ``@PreFilter``).
+
+ Each element is bound to ``filterObject``; elements for which *expression* is
+ False are removed. ``filter_target`` names the collection parameter; when
+ omitted, the first collection-valued argument is used.
+ """
+
+ def decorator(func: F) -> F:
+ signature = inspect.signature(func)
+
+ def _filtered_call(args: tuple[Any, ...], kwargs: dict[str, Any]) -> tuple[tuple[Any, ...], dict[str, Any]]:
+ bound = signature.bind(*args, **kwargs)
+ bound.apply_defaults()
+ target = filter_target or _first_collection_param(bound.arguments)
+ if target is None or target not in bound.arguments:
+ return args, kwargs
+ collection = bound.arguments[target]
+ if not isinstance(collection, _COLLECTION_TYPES):
+ return args, kwargs
+ bound.arguments[target] = _filter_collection(expression, collection, dict(bound.arguments))
+ return bound.args, bound.kwargs
+
+ if asyncio.iscoroutinefunction(func):
+
+ @functools.wraps(func)
+ async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
+ new_args, new_kwargs = _filtered_call(args, kwargs)
+ return await func(*new_args, **new_kwargs)
+
+ async_wrapper.__pyfly_pre_filter__ = expression # type: ignore[attr-defined]
+ return async_wrapper # type: ignore[return-value]
+
+ @functools.wraps(func)
+ def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
+ new_args, new_kwargs = _filtered_call(args, kwargs)
+ return func(*new_args, **new_kwargs)
+
+ sync_wrapper.__pyfly_pre_filter__ = expression # type: ignore[attr-defined]
+ return sync_wrapper # type: ignore[return-value]
+
+ return decorator
+
+
+def post_filter(expression: str) -> Callable[[F], F]:
+ """Filter the returned collection after the method runs (Spring ``@PostFilter``).
+
+ Each returned element is bound to ``filterObject``; non-collection results are
+ returned unchanged.
+ """
+
+ def decorator(func: F) -> F:
+ if asyncio.iscoroutinefunction(func):
+
+ @functools.wraps(func)
+ async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
+ result = await func(*args, **kwargs)
+ if not isinstance(result, _COLLECTION_TYPES):
+ return result
+ return _filter_collection(expression, result, _bind_args(func, args, kwargs))
+
+ async_wrapper.__pyfly_post_filter__ = expression # type: ignore[attr-defined]
+ return async_wrapper # type: ignore[return-value]
+
+ @functools.wraps(func)
+ def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
+ result = func(*args, **kwargs)
+ if not isinstance(result, _COLLECTION_TYPES):
+ return result
+ return _filter_collection(expression, result, _bind_args(func, args, kwargs))
+
+ sync_wrapper.__pyfly_post_filter__ = expression # type: ignore[attr-defined]
+ return sync_wrapper # type: ignore[return-value]
+
+ return decorator
+
+
def pre_authorize(expression: str) -> Callable[[F], F]:
"""Decorator that checks a security expression BEFORE method execution.
diff --git a/src/pyfly/security/oauth2/__init__.py b/src/pyfly/security/oauth2/__init__.py
index 2a2c88d3..78a55d29 100644
--- a/src/pyfly/security/oauth2/__init__.py
+++ b/src/pyfly/security/oauth2/__init__.py
@@ -26,13 +26,20 @@
google,
keycloak,
)
+from pyfly.security.oauth2.endpoints import AuthorizationServerEndpoints
from pyfly.security.oauth2.login import OAuth2LoginHandler
from pyfly.security.oauth2.properties import ResourceServerProperties
-from pyfly.security.oauth2.resource_server import ClaimMappings, JWKSTokenValidator, discover_oidc
+from pyfly.security.oauth2.resource_server import (
+ ClaimMappings,
+ JWKSTokenValidator,
+ OpaqueTokenIntrospector,
+ discover_oidc,
+)
from pyfly.security.oauth2.session_security_filter import OAuth2SessionSecurityFilter
__all__ = [
"AuthorizationServer",
+ "AuthorizationServerEndpoints",
"ClaimMappings",
"ClientRegistration",
"ClientRegistrationRepository",
@@ -41,6 +48,7 @@
"JWKSTokenValidator",
"OAuth2LoginHandler",
"OAuth2SessionSecurityFilter",
+ "OpaqueTokenIntrospector",
"ResourceServerProperties",
"TokenStore",
"discover_oidc",
diff --git a/src/pyfly/security/oauth2/authorization_server.py b/src/pyfly/security/oauth2/authorization_server.py
index eee3d5cc..ab075992 100644
--- a/src/pyfly/security/oauth2/authorization_server.py
+++ b/src/pyfly/security/oauth2/authorization_server.py
@@ -15,6 +15,8 @@
from __future__ import annotations
+import base64
+import hashlib
import secrets
import time
from typing import Any, Protocol
@@ -24,6 +26,12 @@
from pyfly.kernel.exceptions import SecurityException
from pyfly.security.oauth2.client import ClientRegistration, ClientRegistrationRepository
+
+def _s256(verifier: str) -> str:
+ """PKCE S256 transform: base64url(SHA-256(verifier)), no padding (RFC 7636)."""
+ return base64.urlsafe_b64encode(hashlib.sha256(verifier.encode("ascii")).digest()).rstrip(b"=").decode("ascii")
+
+
# ---------------------------------------------------------------------------
# Token Store port and in-memory adapter
# ---------------------------------------------------------------------------
@@ -68,12 +76,23 @@ class AuthorizationServer:
- refresh_token: exchange a refresh token for a new access token
Args:
- secret: Secret key for signing tokens (HS256).
+ secret: Secret key for HMAC signing (used when ``algorithm`` is ``HS*``).
client_repository: Repository to look up client registrations.
token_store: Store for refresh tokens.
access_token_ttl: Access token lifetime in seconds (default: 3600 = 1 hour).
refresh_token_ttl: Refresh token lifetime in seconds (default: 86400 = 24 hours).
issuer: Token issuer claim (optional).
+ audience: Audience the issued tokens are restricted to (``aud`` claim).
+ Accepts a single value or a list. When unset, no ``aud`` is emitted
+ (backward compatible). Setting it lets resource servers reject tokens
+ minted for a different API (RFC 9700 / OAuth 2.1 audience restriction).
+ algorithm: JWS algorithm. ``HS256`` (default) signs with ``secret``;
+ ``RS256``/``RS384``/``RS512``/``PS*``/``ES256``/``ES384``/``ES512``
+ sign with ``private_key`` and publish the matching public key via
+ :meth:`jwks`, so a resource server can verify AS-minted tokens.
+ private_key: PEM string/bytes or a cryptography private-key object, required
+ for asymmetric algorithms.
+ key_id: ``kid`` placed in the JWT header and the published JWK.
"""
def __init__(
@@ -84,13 +103,200 @@ def __init__(
access_token_ttl: int = 3600,
refresh_token_ttl: int = 86400,
issuer: str | None = None,
+ audience: str | list[str] | None = None,
+ algorithm: str = "HS256",
+ private_key: Any = None,
+ key_id: str | None = None,
+ allow_dynamic_registration: bool = False,
+ registration_access_token: str | None = None,
+ auth_code_ttl: int = 60,
) -> None:
self._secret = secret
+ self.allow_dynamic_registration = allow_dynamic_registration
+ self.registration_access_token = registration_access_token
+ self._auth_code_ttl = auth_code_ttl
self._client_repository = client_repository
self._token_store = token_store
self._access_token_ttl = access_token_ttl
self._refresh_token_ttl = refresh_token_ttl
self._issuer = issuer
+ self._algorithm = algorithm.upper()
+ self._is_asymmetric = self._algorithm[:2] in ("RS", "ES", "PS")
+ self._key_id = key_id
+ self._private_key: Any = self._coerce_private_key(private_key) if self._is_asymmetric else None
+ if self._is_asymmetric and self._private_key is None:
+ raise ValueError(f"algorithm {self._algorithm} requires a private_key")
+ if audience is None:
+ self._audience: str | list[str] | None = None
+ elif isinstance(audience, str):
+ self._audience = audience
+ else:
+ aud_list = [a for a in audience if a]
+ self._audience = aud_list or None
+
+ @property
+ def issuer(self) -> str | None:
+ """The configured issuer identifier, if any."""
+ return self._issuer
+
+ @property
+ def signing_algorithm(self) -> str:
+ """The JWS algorithm used to sign tokens (e.g. ``HS256``/``RS256``)."""
+ return self._algorithm
+
+ @staticmethod
+ def _coerce_private_key(private_key: Any) -> Any:
+ """Load a PEM string/bytes into a key object; pass through key objects."""
+ if isinstance(private_key, (str, bytes)):
+ from cryptography.hazmat.primitives.serialization import load_pem_private_key
+
+ data = private_key.encode("utf-8") if isinstance(private_key, str) else private_key
+ return load_pem_private_key(data, password=None)
+ return private_key
+
+ def _encode(self, payload: dict[str, Any]) -> str:
+ """Sign *payload* with the configured algorithm (HMAC or asymmetric+kid)."""
+ if self._is_asymmetric:
+ assert self._private_key is not None # guaranteed by __init__
+ headers = {"kid": self._key_id} if self._key_id else None
+ return pyjwt.encode(payload, self._private_key, algorithm=self._algorithm, headers=headers)
+ return pyjwt.encode(payload, self._secret, algorithm=self._algorithm)
+
+ def jwks(self) -> dict[str, Any]:
+ """Return the public JWK Set for token verification (empty for HMAC)."""
+ if not self._is_asymmetric or self._private_key is None:
+ return {"keys": []}
+ import json as _json
+
+ assert self._private_key is not None # narrowed for mypy
+ public_key = self._private_key.public_key()
+ if self._algorithm[:2] == "ES":
+ jwk = _json.loads(pyjwt.algorithms.ECAlgorithm.to_jwk(public_key))
+ else:
+ jwk = _json.loads(pyjwt.algorithms.RSAAlgorithm.to_jwk(public_key))
+ jwk.update({"use": "sig", "alg": self._algorithm})
+ if self._key_id:
+ jwk["kid"] = self._key_id
+ return {"keys": [jwk]}
+
+ async def register_client(self, metadata: dict[str, Any]) -> dict[str, Any]:
+ """Register a new client dynamically (RFC 7591) and return its metadata.
+
+ Requires ``allow_dynamic_registration`` and a client repository that
+ supports ``add``. Generates the ``client_id`` / ``client_secret``; the
+ endpoint layer enforces any initial access token.
+ """
+ if not self.allow_dynamic_registration:
+ raise SecurityException("Dynamic client registration is disabled", code="REGISTRATION_DISABLED")
+ repo = self._client_repository
+ add = getattr(repo, "add", None)
+ if not callable(add):
+ raise SecurityException(
+ "The configured client repository does not support registration", code="REGISTRATION_UNSUPPORTED"
+ )
+ grant_types = metadata.get("grant_types") or ["client_credentials"]
+ redirect_uris = metadata.get("redirect_uris") or []
+ scope = metadata.get("scope", "")
+ scopes = scope.split() if isinstance(scope, str) else list(scope or [])
+ client_id = secrets.token_urlsafe(16)
+ client_secret = secrets.token_urlsafe(32)
+ registration = ClientRegistration(
+ registration_id=client_id,
+ client_id=client_id,
+ client_secret=client_secret,
+ authorization_grant_type=str(grant_types[0]),
+ redirect_uri=str(redirect_uris[0]) if redirect_uris else "",
+ scopes=scopes,
+ provider_name=str(metadata.get("client_name", "")),
+ )
+ add(registration)
+ return {
+ "client_id": client_id,
+ "client_secret": client_secret,
+ "client_id_issued_at": int(time.time()),
+ "client_secret_expires_at": 0, # never expires
+ "grant_types": list(grant_types),
+ "redirect_uris": list(redirect_uris),
+ "scope": " ".join(scopes),
+ "token_endpoint_auth_method": "client_secret_basic",
+ "client_name": str(metadata.get("client_name", "")),
+ }
+
+ def authenticate_client(self, client_id: str, client_secret: str) -> ClientRegistration | None:
+ """Return the registration iff *client_id*/*client_secret* match (constant time).
+
+ Client authentication requires real credentials: an empty client id or
+ secret — or a registration that has no secret configured — never
+ authenticates (prevents an empty-credential bypass on the management
+ endpoints and for any client that is not a confidential client).
+ """
+ if not client_id or not client_secret:
+ return None
+ registration = self._client_repository.find_by_registration_id(client_id)
+ if registration is None or not registration.client_secret:
+ return None
+ if not secrets.compare_digest(registration.client_secret.encode("utf-8"), client_secret.encode("utf-8")):
+ return None
+ return registration
+
+ def _verification_key(self) -> Any:
+ return self._private_key.public_key() if self._is_asymmetric else self._secret
+
+ async def introspect(
+ self, token: str, *, requesting_client_id: str | None = None, allow_any_client: bool = False
+ ) -> dict[str, Any]:
+ """RFC 7662 token introspection for an access (JWT) or refresh token.
+
+ When *requesting_client_id* is given and *allow_any_client* is False, a
+ token owned by a different client is reported as inactive — so a client
+ cannot scan another client's tokens (information disclosure). Designated
+ resource-server clients pass ``allow_any_client=True``.
+ """
+ result = await self._introspect(token)
+ if (
+ result.get("active")
+ and requesting_client_id is not None
+ and not allow_any_client
+ and result.get("client_id") != requesting_client_id
+ ):
+ return {"active": False}
+ return result
+
+ async def _introspect(self, token: str) -> dict[str, Any]:
+ # Access token: a self-contained, signature-verified JWT.
+ try:
+ payload = pyjwt.decode(
+ token,
+ self._verification_key(),
+ algorithms=[self._algorithm],
+ options={"require": ["exp"], "verify_aud": False},
+ )
+ active: dict[str, Any] = {"active": True, "token_type": "Bearer"}
+ for claim in ("sub", "scope", "iat", "exp", "iss", "aud"):
+ if claim in payload:
+ active[claim] = payload[claim]
+ active.setdefault("client_id", payload.get("sub"))
+ return active
+ except pyjwt.PyJWTError:
+ pass
+
+ # Refresh token: opaque, looked up in the store; active iff present,
+ # unused, unexpired, and its family is still active.
+ data = await self._token_store.find(token)
+ if data is None or data.get("used") or data.get("exp", 0) < int(time.time()):
+ return {"active": False}
+ family_id = data.get("family_id")
+ if family_id:
+ family = await self._token_store.find(self._family_key(family_id))
+ if family is not None and not family.get("active", True):
+ return {"active": False}
+ return {
+ "active": True,
+ "token_type": "refresh_token",
+ "client_id": data.get("client_id"),
+ "scope": data.get("scope", ""),
+ "exp": data.get("exp"),
+ }
async def token(
self,
@@ -99,31 +305,47 @@ async def token(
client_secret: str,
scope: str = "",
refresh_token: str | None = None,
+ confirmation: dict[str, Any] | None = None,
+ code: str | None = None,
+ redirect_uri: str | None = None,
+ code_verifier: str | None = None,
) -> dict[str, Any]:
"""Issue tokens based on grant type.
Args:
- grant_type: "client_credentials" or "refresh_token"
+ grant_type: "client_credentials", "refresh_token" or "authorization_code"
client_id: The client's ID
- client_secret: The client's secret
+ client_secret: The client's secret (confidential clients)
scope: Space-separated scopes (for client_credentials)
refresh_token: The refresh token (for refresh_token grant)
+ confirmation: Optional ``cnf`` confirmation claim to bind the access
+ token to a key (e.g. ``{"jkt": ...}`` for DPoP, ``{"x5t#S256": ...}``
+ for mTLS) — sender-constraining per RFC 9449 / RFC 8705.
+ code: Authorization code (for authorization_code grant).
+ redirect_uri: Redirect URI used in the authorization request (must match).
+ code_verifier: PKCE verifier (for authorization_code grant).
Returns:
Token response dict with access_token, token_type, expires_in,
- and optionally refresh_token.
+ and optionally refresh_token / id_token.
Raises:
SecurityException: If authentication fails or grant type is unsupported.
"""
- # Authenticate client (constant-time secret comparison to avoid a timing
- # side-channel that could leak the client secret).
registration = self._client_repository.find_by_registration_id(client_id)
- if registration is None or not secrets.compare_digest(
- registration.client_secret.encode("utf-8"), client_secret.encode("utf-8")
- ):
+ if registration is None:
raise SecurityException("Invalid client credentials", code="INVALID_CLIENT")
+ # Client authentication: a confidential client (one with a registered
+ # secret) MUST present it. A public client (no secret) is permitted only
+ # for the authorization_code grant, where PKCE provides proof of possession.
+ is_public = not registration.client_secret
+ if not is_public:
+ if self.authenticate_client(client_id, client_secret) is None:
+ raise SecurityException("Invalid client credentials", code="INVALID_CLIENT")
+ elif grant_type != "authorization_code":
+ raise SecurityException("Public clients may not use this grant", code="INVALID_CLIENT")
+
if grant_type == "client_credentials":
# The client must be registered for the client_credentials grant to
# mint a client_credentials token — prevents grant-type confusion (a
@@ -133,54 +355,284 @@ async def token(
f"Client '{client_id}' is not authorized for grant type 'client_credentials'",
code="UNAUTHORIZED_CLIENT",
)
- return await self._handle_client_credentials(registration, scope)
+ return await self._handle_client_credentials(registration, scope, confirmation)
elif grant_type == "refresh_token":
if refresh_token is None:
raise SecurityException("Refresh token required", code="INVALID_REQUEST")
- return await self._handle_refresh_token(registration, refresh_token)
+ return await self._handle_refresh_token(registration, refresh_token, confirmation)
+ elif grant_type == "authorization_code":
+ if code is None:
+ raise SecurityException("Authorization code required", code="INVALID_REQUEST")
+ return await self._handle_authorization_code(registration, code, redirect_uri, code_verifier, confirmation)
else:
raise SecurityException(
f"Unsupported grant type: {grant_type}",
code="UNSUPPORTED_GRANT_TYPE",
)
- async def _handle_client_credentials(self, registration: ClientRegistration, scope: str) -> dict[str, Any]:
+ # ------------------------------------------------------------------
+ # Authorization Code grant (RFC 6749 §4.1 + PKCE RFC 7636 + OIDC)
+ # ------------------------------------------------------------------
+
+ @staticmethod
+ def _code_key(code: str) -> str:
+ return f"authcode:{code}"
+
+ async def authorize(
+ self,
+ *,
+ client_id: str,
+ redirect_uri: str,
+ user_id: str,
+ response_type: str = "code",
+ scope: str = "",
+ state: str | None = None,
+ code_challenge: str | None = None,
+ code_challenge_method: str = "S256",
+ nonce: str | None = None,
+ ) -> dict[str, Any]:
+ """Process an authorization request and issue a single-use authorization code.
+
+ *user_id* is the already-authenticated resource owner. Enforces exact
+ redirect-URI matching, scope subset, and **mandatory PKCE (S256)** (OAuth
+ 2.1 / RFC 9700). Returns ``{code, redirect_uri[, state][, iss]}``.
+
+ Raises:
+ SecurityException: ``INVALID_CLIENT`` / ``INVALID_REDIRECT_URI`` must NOT
+ be redirected back to the client; other codes are safe to surface to the
+ client via a redirect ``error`` parameter.
+ """
+ registration = self._client_repository.find_by_registration_id(client_id)
+ if registration is None:
+ raise SecurityException("Unknown client", code="INVALID_CLIENT")
+ # Exact redirect-URI match (RFC 9700 / OAuth 2.1) — never redirect on mismatch.
+ if redirect_uri != registration.redirect_uri:
+ raise SecurityException("redirect_uri does not match the registration", code="INVALID_REDIRECT_URI")
+ if response_type != "code":
+ raise SecurityException(f"Unsupported response_type: {response_type}", code="UNSUPPORTED_RESPONSE_TYPE")
+
+ requested = scope.split() if scope else list(registration.scopes)
+ unregistered = [s for s in requested if s not in registration.scopes]
+ if unregistered:
+ raise SecurityException(f"Unpermitted scope(s): {' '.join(unregistered)}", code="INVALID_SCOPE")
+
+ # PKCE is mandatory and must be S256 (OAuth 2.1 §4.1.1 / RFC 7636).
+ if not code_challenge:
+ raise SecurityException("PKCE code_challenge is required", code="INVALID_REQUEST")
+ if code_challenge_method != "S256":
+ raise SecurityException("Only the S256 PKCE method is supported", code="INVALID_REQUEST")
+
+ code = secrets.token_urlsafe(32)
+ await self._token_store.store(
+ self._code_key(code),
+ {
+ "client_id": registration.client_id,
+ "redirect_uri": redirect_uri,
+ "scope": " ".join(requested),
+ "code_challenge": code_challenge,
+ "user_id": user_id,
+ "nonce": nonce,
+ "exp": int(time.time()) + self._auth_code_ttl,
+ "used": False,
+ },
+ )
+ result: dict[str, Any] = {"code": code, "redirect_uri": redirect_uri}
+ if state is not None:
+ result["state"] = state
+ if self._issuer:
+ result["iss"] = self._issuer # RFC 9207 mix-up defense
+ return result
+
+ # ------------------------------------------------------------------
+ # Pushed Authorization Requests (RFC 9126) + request objects (RFC 9101)
+ # ------------------------------------------------------------------
+
+ @staticmethod
+ def _par_key(request_uri: str) -> str:
+ return f"par:{request_uri}"
+
+ async def pushed_authorization_request(self, client_id: str, params: dict[str, Any]) -> dict[str, Any]:
+ """Store a pushed authorization request and return its ``request_uri`` (RFC 9126)."""
+ request_uri = "urn:ietf:params:oauth:request_uri:" + secrets.token_urlsafe(24)
+ await self._token_store.store(
+ self._par_key(request_uri),
+ {"client_id": client_id, "params": dict(params), "exp": int(time.time()) + 90},
+ )
+ return {"request_uri": request_uri, "expires_in": 90}
+
+ async def consume_pushed_request(self, request_uri: str, client_id: str) -> dict[str, Any] | None:
+ """Return (and one-time consume) the params for *request_uri* if valid for *client_id*."""
+ data = await self._token_store.find(self._par_key(request_uri))
+ if data is None or data.get("client_id") != client_id or data.get("exp", 0) < int(time.time()):
+ return None
+ await self._token_store.revoke(self._par_key(request_uri))
+ return dict(data.get("params") or {})
+
+ def verify_request_object(self, client_id: str, request_jwt: str) -> dict[str, Any]:
+ """Verify a JAR request object (RFC 9101) signed with the client secret (HS256)."""
+ registration = self._client_repository.find_by_registration_id(client_id)
+ if registration is None or not registration.client_secret:
+ raise SecurityException("Request objects require a confidential client", code="INVALID_REQUEST")
+ try:
+ claims: dict[str, Any] = pyjwt.decode(
+ request_jwt, registration.client_secret, algorithms=["HS256"], options={"verify_aud": False}
+ )
+ except pyjwt.PyJWTError as exc:
+ raise SecurityException(f"Invalid request object: {exc}", code="INVALID_REQUEST") from exc
+ return claims
+
+ async def _handle_authorization_code(
+ self,
+ registration: ClientRegistration,
+ code: str,
+ redirect_uri: str | None,
+ code_verifier: str | None,
+ confirmation: dict[str, Any] | None,
+ ) -> dict[str, Any]:
+ key = self._code_key(code)
+ data = await self._token_store.find(key)
+ if data is None:
+ raise SecurityException("Invalid authorization code", code="INVALID_GRANT")
+
+ # Single-use: a replayed code is treated as injection — revoke any tokens
+ # already issued from it (RFC 9700) and reject.
+ if data.get("used"):
+ issued_refresh = data.get("issued_refresh")
+ if issued_refresh:
+ await self.revoke(issued_refresh)
+ raise SecurityException("Authorization code already used", code="INVALID_GRANT")
+ if data.get("client_id") != registration.client_id:
+ raise SecurityException("Authorization code was issued to another client", code="INVALID_GRANT")
+ if redirect_uri is not None and data.get("redirect_uri") != redirect_uri:
+ raise SecurityException("redirect_uri mismatch", code="INVALID_GRANT")
+ if data.get("exp", 0) < int(time.time()):
+ await self._token_store.revoke(key)
+ raise SecurityException("Authorization code expired", code="INVALID_GRANT")
+
+ # PKCE verification (mandatory).
+ challenge = data.get("code_challenge")
+ if not code_verifier or _s256(code_verifier) != challenge:
+ raise SecurityException("PKCE verification failed", code="INVALID_GRANT")
+
now = int(time.time())
- scopes = scope.split() if scope else registration.scopes
+ scope = data.get("scope", "")
+ user_id = data.get("user_id")
access_payload: dict[str, Any] = {
- "sub": registration.client_id,
- "scope": " ".join(scopes),
+ "sub": user_id,
+ "scope": scope,
"iat": now,
"exp": now + self._access_token_ttl,
}
if self._issuer:
access_payload["iss"] = self._issuer
+ if self._audience is not None:
+ access_payload["aud"] = self._audience
+ if confirmation:
+ access_payload["cnf"] = confirmation
+ access_token = self._encode(access_payload)
+
+ refresh_id = await self._issue_refresh_token(registration.client_id, scope)
+
+ # Mark the code consumed and remember the refresh token so a replay can
+ # revoke it (authorization-code injection defense).
+ data["used"] = True
+ data["issued_refresh"] = refresh_id
+ await self._token_store.store(key, data)
+
+ response: dict[str, Any] = {
+ "access_token": access_token,
+ "token_type": "Bearer",
+ "expires_in": self._access_token_ttl,
+ "refresh_token": refresh_id,
+ "scope": scope,
+ }
+ if "openid" in scope.split():
+ response["id_token"] = self._issue_id_token(str(user_id), registration.client_id, data.get("nonce"))
+ return response
- access_token = pyjwt.encode(access_payload, self._secret, algorithm="HS256")
+ def _issue_id_token(self, subject: str, client_id: str, nonce: str | None) -> str:
+ """Issue an OIDC ID token (aud = client_id) for the openid scope."""
+ now = int(time.time())
+ payload: dict[str, Any] = {
+ "sub": subject,
+ "aud": client_id,
+ "iat": now,
+ "exp": now + self._access_token_ttl,
+ }
+ if self._issuer:
+ payload["iss"] = self._issuer
+ if nonce:
+ payload["nonce"] = nonce
+ return self._encode(payload)
+
+ async def _handle_client_credentials(
+ self, registration: ClientRegistration, scope: str, confirmation: dict[str, Any] | None = None
+ ) -> dict[str, Any]:
+ now = int(time.time())
+ # A client may only ever obtain scopes it is registered for. Requesting an
+ # unregistered scope is rejected wholesale (RFC 6749 §5.2 ``invalid_scope``)
+ # rather than silently echoed — otherwise any authenticated client could
+ # mint an arbitrarily-privileged token (e.g. ``admin``) just by asking.
+ if scope:
+ requested = scope.split()
+ unregistered = [s for s in requested if s not in registration.scopes]
+ if unregistered:
+ raise SecurityException(
+ f"Requested scope(s) not permitted for this client: {' '.join(unregistered)}",
+ code="INVALID_SCOPE",
+ )
+ scopes = requested
+ else:
+ scopes = registration.scopes
- # Generate refresh token
- refresh_token_id = secrets.token_urlsafe(32)
- refresh_data = {
- "client_id": registration.client_id,
+ access_payload: dict[str, Any] = {
+ "sub": registration.client_id,
"scope": " ".join(scopes),
- "exp": now + self._refresh_token_ttl,
+ "iat": now,
+ "exp": now + self._access_token_ttl,
}
- await self._token_store.store(refresh_token_id, refresh_data)
+ if self._issuer:
+ access_payload["iss"] = self._issuer
+ if self._audience is not None:
+ access_payload["aud"] = self._audience
+ if confirmation:
+ access_payload["cnf"] = confirmation
+
+ access_token = self._encode(access_payload)
+
+ scope_str = " ".join(scopes)
+ refresh_token_id = await self._issue_refresh_token(registration.client_id, scope_str)
return {
"access_token": access_token,
"token_type": "Bearer",
"expires_in": self._access_token_ttl,
"refresh_token": refresh_token_id,
- "scope": " ".join(scopes),
+ "scope": scope_str,
}
- async def _handle_refresh_token(self, registration: ClientRegistration, refresh_token: str) -> dict[str, Any]:
+ async def _handle_refresh_token(
+ self, registration: ClientRegistration, refresh_token: str, confirmation: dict[str, Any] | None = None
+ ) -> dict[str, Any]:
token_data = await self._token_store.find(refresh_token)
if token_data is None:
raise SecurityException("Invalid refresh token", code="INVALID_GRANT")
+ family_id = token_data.get("family_id")
+ family = await self._token_store.find(self._family_key(family_id)) if family_id else None
+
+ # The family was already revoked (e.g. by a previous reuse) — refuse.
+ if family is not None and not family.get("active", True):
+ raise SecurityException("Refresh token family revoked", code="INVALID_GRANT")
+
+ # Reuse detection (OAuth 2.1 / RFC 9700): a refresh token that was already
+ # rotated is being replayed. The legitimate holder cannot do this, so we
+ # treat it as theft and revoke the entire token family.
+ if token_data.get("used"):
+ await self._revoke_family(family_id, family)
+ raise SecurityException("Refresh token reuse detected", code="INVALID_GRANT")
+
# Verify client matches
if token_data.get("client_id") != registration.client_id:
raise SecurityException("Refresh token client mismatch", code="INVALID_GRANT")
@@ -190,8 +642,10 @@ async def _handle_refresh_token(self, registration: ClientRegistration, refresh_
await self._token_store.revoke(refresh_token)
raise SecurityException("Refresh token expired", code="INVALID_GRANT")
- # Revoke old refresh token (rotation)
- await self._token_store.revoke(refresh_token)
+ # Mark the presented token consumed (rotation). It is retained — not
+ # deleted — so a later replay is detected as reuse rather than "unknown".
+ token_data["used"] = True
+ await self._token_store.store(refresh_token, token_data)
# Issue new tokens
now = int(time.time())
@@ -205,17 +659,14 @@ async def _handle_refresh_token(self, registration: ClientRegistration, refresh_
}
if self._issuer:
access_payload["iss"] = self._issuer
+ if self._audience is not None:
+ access_payload["aud"] = self._audience
+ if confirmation:
+ access_payload["cnf"] = confirmation
- access_token = pyjwt.encode(access_payload, self._secret, algorithm="HS256")
+ access_token = self._encode(access_payload)
- # New refresh token
- new_refresh_id = secrets.token_urlsafe(32)
- new_refresh_data = {
- "client_id": registration.client_id,
- "scope": scope,
- "exp": now + self._refresh_token_ttl,
- }
- await self._token_store.store(new_refresh_id, new_refresh_data)
+ new_refresh_id = await self._issue_refresh_token(registration.client_id, scope, family_id)
return {
"access_token": access_token,
@@ -225,6 +676,64 @@ async def _handle_refresh_token(self, registration: ClientRegistration, refresh_
"scope": scope,
}
- async def revoke(self, token_id: str) -> None:
- """Revoke a refresh token."""
+ # ------------------------------------------------------------------
+ # Refresh-token family bookkeeping (rotation + reuse detection)
+ # ------------------------------------------------------------------
+
+ @staticmethod
+ def _family_key(family_id: str | None) -> str:
+ return f"family:{family_id}"
+
+ async def _issue_refresh_token(self, client_id: str, scope: str, family_id: str | None = None) -> str:
+ """Mint a refresh token, creating or extending its rotation family."""
+ token_id = secrets.token_urlsafe(32)
+ if family_id is None:
+ family_id = secrets.token_urlsafe(16)
+ family: dict[str, Any] = {"client_id": client_id, "active": True, "members": [token_id]}
+ else:
+ family = await self._token_store.find(self._family_key(family_id)) or {
+ "client_id": client_id,
+ "active": True,
+ "members": [],
+ }
+ family.setdefault("members", []).append(token_id)
+ token_data = {
+ "client_id": client_id,
+ "scope": scope,
+ "exp": int(time.time()) + self._refresh_token_ttl,
+ "family_id": family_id,
+ "used": False,
+ }
+ await self._token_store.store(token_id, token_data)
+ await self._token_store.store(self._family_key(family_id), family)
+ return token_id
+
+ async def _revoke_family(self, family_id: str | None, family: dict[str, Any] | None = None) -> None:
+ """Revoke an entire refresh-token family (all rotated descendants)."""
+ if family_id is None:
+ return
+ if family is None:
+ family = await self._token_store.find(self._family_key(family_id))
+ if family is None:
+ return
+ family["active"] = False
+ await self._token_store.store(self._family_key(family_id), family)
+ for token_id in family.get("members", []):
+ await self._token_store.revoke(token_id)
+
+ async def revoke(self, token_id: str, *, requesting_client_id: str | None = None) -> None:
+ """Revoke a refresh token (and, when known, its whole rotation family).
+
+ Per RFC 7009 §2.1, when *requesting_client_id* is given the token is only
+ revoked if it was issued to that client — a client cannot revoke another
+ client's tokens. ``requesting_client_id=None`` (internal callers) revokes
+ unconditionally.
+ """
+ token_data = await self._token_store.find(token_id)
+ owner = token_data.get("client_id") if isinstance(token_data, dict) else None
+ if requesting_client_id is not None and owner is not None and owner != requesting_client_id:
+ return # not the owner — refuse silently (RFC 7009 still returns 200)
await self._token_store.revoke(token_id)
+ family_id = token_data.get("family_id") if isinstance(token_data, dict) else None
+ if family_id:
+ await self._revoke_family(family_id)
diff --git a/src/pyfly/security/oauth2/client.py b/src/pyfly/security/oauth2/client.py
index 31657c3e..615854fa 100644
--- a/src/pyfly/security/oauth2/client.py
+++ b/src/pyfly/security/oauth2/client.py
@@ -39,9 +39,20 @@ class ClientRegistration:
jwks_uri: str = ""
issuer_uri: str = ""
provider_name: str = ""
- # Enable PKCE (RFC 7636, S256) on the authorization_code flow. Recommended for public
- # clients (no client_secret); harmless and more secure for confidential clients too.
- use_pkce: bool = False
+ # Enable PKCE (RFC 7636, S256) on the authorization_code flow. On by default —
+ # RFC 9700 / OAuth 2.1 require PKCE for the authorization code grant for all
+ # client types. A public client (empty client_secret) always uses PKCE even if
+ # this is set False, as it has no other defense against code injection. Set
+ # False only for a confidential client talking to an AS that rejects PKCE.
+ use_pkce: bool = True
+ # Require the RFC 9207 ``iss`` authorization-response parameter to be present
+ # and match ``issuer_uri`` on callback (mix-up-attack defense). When False
+ # (default) the ``iss`` param is still validated *when present*, but a provider
+ # that omits it is tolerated.
+ require_iss: bool = False
+ # Marks a resource-server client permitted to introspect tokens it does not
+ # own (RFC 7662). Regular clients may only introspect their own tokens.
+ allow_introspection: bool = False
# ---------------------------------------------------------------------------
diff --git a/src/pyfly/security/oauth2/dpop.py b/src/pyfly/security/oauth2/dpop.py
new file mode 100644
index 00000000..2508253e
--- /dev/null
+++ b/src/pyfly/security/oauth2/dpop.py
@@ -0,0 +1,168 @@
+# Copyright 2026 Firefly Software Foundation.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Sender-constrained access tokens — DPoP (RFC 9449) and mTLS (RFC 8705).
+
+A bearer token can be replayed by anyone who steals it. Sender-constraining binds
+the token to a key the legitimate client holds:
+
+* **DPoP** — the client signs a per-request *proof* JWT with its private key; the
+ access token carries ``cnf.jkt`` (the JWK SHA-256 thumbprint, RFC 7638). The
+ resource server verifies the proof and that its key thumbprint matches ``jkt``.
+* **mTLS** — the access token carries ``cnf["x5t#S256"]`` (the client certificate
+ thumbprint). The resource server compares it to the presented client cert.
+"""
+
+from __future__ import annotations
+
+import base64
+import hashlib
+import json
+import time
+from typing import Any
+from urllib.parse import urlsplit
+
+import jwt as pyjwt
+
+from pyfly.kernel.exceptions import SecurityException
+
+
+def _b64url(data: bytes) -> str:
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
+
+
+def jwk_thumbprint(jwk: dict[str, Any]) -> str:
+ """Compute the RFC 7638 JWK SHA-256 thumbprint (base64url, no padding)."""
+ kty = jwk.get("kty")
+ if kty == "RSA":
+ members = {"e": jwk["e"], "kty": "RSA", "n": jwk["n"]}
+ elif kty == "EC":
+ members = {"crv": jwk["crv"], "kty": "EC", "x": jwk["x"], "y": jwk["y"]}
+ elif kty == "OKP":
+ members = {"crv": jwk["crv"], "kty": "OKP", "x": jwk["x"]}
+ else:
+ raise SecurityException(f"Unsupported JWK key type for thumbprint: {kty!r}", code="INVALID_TOKEN")
+ canonical = json.dumps(members, separators=(",", ":"), sort_keys=True)
+ return _b64url(hashlib.sha256(canonical.encode("utf-8")).digest())
+
+
+def _normalize_htu(url: str) -> str:
+ """Normalize an HTTP URI for ``htu`` comparison: scheme://host/path (no query/fragment)."""
+ parts = urlsplit(url)
+ return f"{parts.scheme}://{parts.netloc}{parts.path}"
+
+
+def access_token_hash(access_token: str) -> str:
+ """The DPoP ``ath`` value: base64url(SHA-256(access_token))."""
+ return _b64url(hashlib.sha256(access_token.encode("ascii")).digest())
+
+
+class DPoPProofValidator:
+ """Validates a DPoP proof JWT (RFC 9449 §4.3) and returns its key thumbprint.
+
+ Args:
+ max_age_seconds: Accepted ``iat`` skew window for the proof.
+ replay_cache: Optional set-like collection of seen ``jti`` values; when
+ provided, a repeated ``jti`` is rejected as a replay. (Use a bounded /
+ TTL-backed set in production.)
+ """
+
+ def __init__(self, *, max_age_seconds: int = 60, replay_cache: set[str] | None = None) -> None:
+ self._max_age = max_age_seconds
+ self._replay_cache = replay_cache
+
+ def validate(
+ self,
+ proof: str,
+ *,
+ http_method: str,
+ http_url: str,
+ access_token: str | None = None,
+ ) -> str:
+ """Verify *proof* for the given request; return the bound key thumbprint (jkt).
+
+ Raises:
+ SecurityException: if the proof is malformed, mis-signed, stale, replayed,
+ or does not match the request method/URL (or access token hash).
+ """
+ try:
+ header = pyjwt.get_unverified_header(proof)
+ except pyjwt.PyJWTError as exc:
+ raise SecurityException(f"Malformed DPoP proof: {exc}", code="INVALID_DPOP_PROOF") from exc
+
+ if header.get("typ") != "dpop+jwt":
+ raise SecurityException("DPoP proof has wrong 'typ'", code="INVALID_DPOP_PROOF")
+ alg = str(header.get("alg", ""))
+ if alg[:2] not in ("RS", "ES", "PS") and not alg.startswith("Ed"):
+ raise SecurityException("DPoP proof must use an asymmetric algorithm", code="INVALID_DPOP_PROOF")
+ jwk = header.get("jwk")
+ if not isinstance(jwk, dict):
+ raise SecurityException("DPoP proof missing embedded 'jwk'", code="INVALID_DPOP_PROOF")
+ if any(k in jwk for k in ("d", "p", "q", "dp", "dq", "qi")):
+ raise SecurityException("DPoP proof 'jwk' must not contain private material", code="INVALID_DPOP_PROOF")
+
+ try:
+ key = pyjwt.PyJWK.from_dict(jwk).key
+ claims = pyjwt.decode(proof, key, algorithms=[alg], options={"verify_aud": False})
+ except pyjwt.PyJWTError as exc:
+ raise SecurityException(f"DPoP proof signature invalid: {exc}", code="INVALID_DPOP_PROOF") from exc
+
+ if str(claims.get("htm", "")).upper() != http_method.upper():
+ raise SecurityException("DPoP 'htm' does not match the request method", code="INVALID_DPOP_PROOF")
+ if _normalize_htu(str(claims.get("htu", ""))) != _normalize_htu(http_url):
+ raise SecurityException("DPoP 'htu' does not match the request URL", code="INVALID_DPOP_PROOF")
+
+ iat = claims.get("iat")
+ if not isinstance(iat, (int, float)) or abs(time.time() - float(iat)) > self._max_age:
+ raise SecurityException("DPoP proof is stale or missing 'iat'", code="INVALID_DPOP_PROOF")
+
+ jti = claims.get("jti")
+ if self._replay_cache is not None:
+ if not jti or jti in self._replay_cache:
+ raise SecurityException("DPoP proof replayed or missing 'jti'", code="INVALID_DPOP_PROOF")
+ self._replay_cache.add(str(jti))
+
+ if access_token is not None and claims.get("ath") != access_token_hash(access_token):
+ raise SecurityException("DPoP 'ath' does not match the access token", code="INVALID_DPOP_PROOF")
+
+ return jwk_thumbprint(jwk)
+
+
+def confirm_dpop_binding(token_claims: dict[str, Any], jkt: str) -> None:
+ """Assert the access token is DPoP-bound to *jkt* (its ``cnf.jkt``)."""
+ bound = (token_claims.get("cnf") or {}).get("jkt")
+ if not bound:
+ raise SecurityException("Access token is not DPoP-bound (no cnf.jkt)", code="INVALID_TOKEN")
+ if bound != jkt:
+ raise SecurityException("DPoP key does not match the token's cnf.jkt", code="INVALID_TOKEN")
+
+
+def certificate_thumbprint(cert: str | bytes) -> str:
+ """Return the RFC 8705 ``x5t#S256`` thumbprint (base64url SHA-256 of the DER cert)."""
+ from cryptography import x509
+
+ if isinstance(cert, str):
+ cert = cert.encode("utf-8")
+ loaded = x509.load_pem_x509_certificate(cert) if b"-----BEGIN" in cert else x509.load_der_x509_certificate(cert)
+ from cryptography.hazmat.primitives.serialization import Encoding
+
+ return _b64url(hashlib.sha256(loaded.public_bytes(Encoding.DER)).digest())
+
+
+def confirm_mtls_binding(token_claims: dict[str, Any], cert: str | bytes) -> None:
+ """Assert the access token is mTLS-bound to *cert* (its ``cnf["x5t#S256"]``)."""
+ bound = (token_claims.get("cnf") or {}).get("x5t#S256")
+ if not bound:
+ raise SecurityException("Access token is not mTLS-bound (no cnf.x5t#S256)", code="INVALID_TOKEN")
+ if bound != certificate_thumbprint(cert):
+ raise SecurityException("Client certificate does not match the token's cnf.x5t#S256", code="INVALID_TOKEN")
diff --git a/src/pyfly/security/oauth2/endpoints.py b/src/pyfly/security/oauth2/endpoints.py
new file mode 100644
index 00000000..4d7fba8f
--- /dev/null
+++ b/src/pyfly/security/oauth2/endpoints.py
@@ -0,0 +1,301 @@
+# Copyright 2026 Firefly Software Foundation.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""OAuth2 Authorization Server HTTP endpoints.
+
+Exposes, as Starlette routes, the token endpoint plus the standard OAuth2
+management endpoints:
+
+* ``POST /oauth2/token`` — issue tokens (RFC 6749)
+* ``POST /oauth2/introspect`` — token introspection (RFC 7662), client-authenticated
+* ``POST /oauth2/revoke`` — token revocation (RFC 7009), client-authenticated
+* ``GET /oauth2/jwks`` — public JWK Set (for asymmetric signing)
+"""
+
+from __future__ import annotations
+
+import base64
+import binascii
+from secrets import compare_digest as _consteq
+from typing import Any
+from urllib.parse import urlencode
+
+from starlette.requests import Request
+from starlette.responses import JSONResponse, RedirectResponse, Response
+from starlette.routing import Route
+
+from pyfly.kernel.exceptions import SecurityException
+from pyfly.security.context import SecurityContext
+from pyfly.security.oauth2.authorization_server import AuthorizationServer
+from pyfly.security.oauth2.client import ClientRegistration
+
+# OAuth2 error codes that map to a 401 (client authentication failed); the rest
+# are request/grant errors returned as 400 (RFC 6749 §5.2).
+_UNAUTHORIZED_ERRORS = {"INVALID_CLIENT"}
+
+# Authorization-endpoint error codes that may NOT be redirected back to the client
+# (the client/redirect is untrusted), per RFC 6749 §4.1.2.1.
+_NON_REDIRECTABLE = {"INVALID_CLIENT", "INVALID_REDIRECT_URI"}
+
+# Map internal SecurityException codes to RFC 6749 authorization-response errors.
+_AUTHZ_ERROR = {
+ "INVALID_SCOPE": "invalid_scope",
+ "UNSUPPORTED_RESPONSE_TYPE": "unsupported_response_type",
+ "INVALID_REQUEST": "invalid_request",
+}
+
+
+class AuthorizationServerEndpoints:
+ """Builds Starlette routes that expose an :class:`AuthorizationServer`."""
+
+ def __init__(self, server: AuthorizationServer, *, login_url: str = "/login") -> None:
+ self._server = server
+ self._login_url = login_url
+
+ def routes(self) -> list[Route]:
+ return [
+ Route("/oauth2/authorize", self._authorize, methods=["GET"]),
+ Route("/oauth2/par", self._par, methods=["POST"]),
+ Route("/oauth2/token", self._token, methods=["POST"]),
+ Route("/oauth2/introspect", self._introspect, methods=["POST"]),
+ Route("/oauth2/revoke", self._revoke, methods=["POST"]),
+ Route("/oauth2/register", self._register, methods=["POST"]),
+ Route("/oauth2/jwks", self._jwks, methods=["GET"]),
+ Route("/.well-known/oauth-authorization-server", self._oauth_metadata, methods=["GET"]),
+ Route("/.well-known/openid-configuration", self._openid_metadata, methods=["GET"]),
+ ]
+
+ # -- metadata / discovery (RFC 8414 + OIDC discovery) -----------------
+
+ def _metadata(self, request: Request) -> dict[str, Any]:
+ base = str(request.base_url).rstrip("/")
+ return {
+ "issuer": self._server.issuer or base,
+ "authorization_endpoint": f"{base}/oauth2/authorize",
+ "token_endpoint": f"{base}/oauth2/token",
+ "introspection_endpoint": f"{base}/oauth2/introspect",
+ "revocation_endpoint": f"{base}/oauth2/revoke",
+ "registration_endpoint": f"{base}/oauth2/register",
+ "jwks_uri": f"{base}/oauth2/jwks",
+ "response_types_supported": ["code"],
+ "grant_types_supported": ["authorization_code", "client_credentials", "refresh_token"],
+ "token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post", "none"],
+ "code_challenge_methods_supported": ["S256"],
+ }
+
+ async def _oauth_metadata(self, request: Request) -> Response:
+ return JSONResponse(self._metadata(request))
+
+ async def _openid_metadata(self, request: Request) -> Response:
+ doc = self._metadata(request)
+ doc.update(
+ {
+ "subject_types_supported": ["public"],
+ "id_token_signing_alg_values_supported": [self._server.signing_algorithm],
+ "scopes_supported": ["openid"],
+ "claims_supported": ["sub", "aud", "iss", "exp", "iat", "nonce"],
+ }
+ )
+ return JSONResponse(doc)
+
+ # -- authorization endpoint (RFC 6749 §4.1.1) -------------------------
+
+ async def _authorize(self, request: Request) -> Response:
+ ctx = getattr(getattr(request, "state", None), "security_context", None)
+ user_id = ctx.user_id if isinstance(ctx, SecurityContext) and ctx.is_authenticated else None
+ if not user_id:
+ # The resource owner must authenticate first; bounce to login and come back.
+ return RedirectResponse(f"{self._login_url}?{urlencode({'next': str(request.url)})}", status_code=302)
+
+ params: dict[str, str] = dict(request.query_params)
+ client_id = params.get("client_id", "")
+ # PAR (RFC 9126): resolve a one-time request_uri to its pushed params.
+ if params.get("request_uri"):
+ stored = await self._server.consume_pushed_request(params["request_uri"], client_id)
+ if stored is None:
+ return JSONResponse({"error": "invalid_request_uri"}, status_code=400)
+ params = {**{k: str(v) for k, v in stored.items()}, "client_id": client_id}
+ # JAR (RFC 9101): a signed request object supplies the parameters.
+ elif params.get("request"):
+ try:
+ claims = self._server.verify_request_object(client_id, params["request"])
+ except SecurityException as exc:
+ return JSONResponse({"error": "invalid_request_object", "error_description": str(exc)}, status_code=400)
+ params = {**params, **{k: str(v) for k, v in claims.items()}}
+
+ redirect_uri = params.get("redirect_uri", "")
+ state = params.get("state")
+ try:
+ result = await self._server.authorize(
+ client_id=params.get("client_id", ""),
+ redirect_uri=redirect_uri,
+ user_id=user_id,
+ response_type=params.get("response_type", "code"),
+ scope=params.get("scope", ""),
+ state=state,
+ code_challenge=params.get("code_challenge"),
+ code_challenge_method=params.get("code_challenge_method", "S256"),
+ nonce=params.get("nonce"),
+ )
+ except SecurityException as exc:
+ code = exc.code or "INVALID_REQUEST"
+ if code in _NON_REDIRECTABLE:
+ return JSONResponse({"error": code.lower(), "error_description": str(exc)}, status_code=400)
+ query = {"error": _AUTHZ_ERROR.get(code, "invalid_request")}
+ if state is not None:
+ query["state"] = state
+ return RedirectResponse(f"{redirect_uri}?{urlencode(query)}", status_code=302)
+
+ query = {"code": result["code"]}
+ if "state" in result:
+ query["state"] = result["state"]
+ if "iss" in result:
+ query["iss"] = result["iss"]
+ return RedirectResponse(f"{result['redirect_uri']}?{urlencode(query)}", status_code=302)
+
+ # -- dynamic client registration (RFC 7591) ---------------------------
+
+ async def _register(self, request: Request) -> Response:
+ # When an initial access token is configured, it MUST be presented as a
+ # bearer token (RFC 7591 §3); otherwise registration is open.
+ required = self._server.registration_access_token
+ if required:
+ header = request.headers.get("authorization", "")
+ parts = header.split(" ", 1)
+ presented = parts[1].strip() if len(parts) == 2 and parts[0].lower() == "bearer" else ""
+ if not presented or not _consteq(presented, required):
+ return JSONResponse(
+ {"error": "invalid_token"},
+ status_code=401,
+ headers={"WWW-Authenticate": 'Bearer error="invalid_token"'},
+ )
+ try:
+ metadata = await request.json()
+ except Exception: # malformed JSON body
+ metadata = {}
+ try:
+ result = await self._server.register_client(metadata if isinstance(metadata, dict) else {})
+ except SecurityException as exc:
+ return JSONResponse({"error": (exc.code or "invalid_request").lower()}, status_code=403)
+ return JSONResponse(result, status_code=201)
+
+ # -- pushed authorization requests (RFC 9126) -------------------------
+
+ async def _par(self, request: Request) -> Response:
+ form = await request.form()
+ registration = self._authenticate(request, form)
+ if registration is None:
+ return self._error(SecurityException("Invalid client", code="INVALID_CLIENT"))
+ params = {k: str(v) for k, v in form.items() if k not in ("client_id", "client_secret")}
+ result = await self._server.pushed_authorization_request(registration.client_id, params)
+ return JSONResponse(result, status_code=201)
+
+ # -- token endpoint ----------------------------------------------------
+
+ async def _token(self, request: Request) -> Response:
+ form = await request.form()
+ client_id, client_secret = self._client_credentials(request, form)
+ # DPoP (RFC 9449): if the client presents a proof on the token request, bind
+ # the issued access token to its key via a cnf.jkt confirmation claim.
+ confirmation: dict[str, Any] | None = None
+ dpop_proof = request.headers.get("dpop")
+ if dpop_proof:
+ from pyfly.security.oauth2.dpop import DPoPProofValidator
+
+ try:
+ jkt = DPoPProofValidator().validate(dpop_proof, http_method="POST", http_url=str(request.url))
+ except SecurityException as exc:
+ return self._error(exc)
+ confirmation = {"jkt": jkt}
+ try:
+ result = await self._server.token(
+ grant_type=str(form.get("grant_type", "")),
+ client_id=client_id,
+ client_secret=client_secret,
+ scope=str(form.get("scope", "")),
+ refresh_token=(str(form["refresh_token"]) if form.get("refresh_token") else None),
+ confirmation=confirmation,
+ )
+ except SecurityException as exc:
+ return self._error(exc)
+ return JSONResponse(result)
+
+ # -- introspection (RFC 7662) -----------------------------------------
+
+ async def _introspect(self, request: Request) -> Response:
+ form = await request.form()
+ registration = self._authenticate(request, form)
+ if registration is None:
+ return self._error(SecurityException("Invalid client", code="INVALID_CLIENT"))
+ token = str(form.get("token", ""))
+ if not token:
+ return JSONResponse({"active": False})
+ result = await self._server.introspect(
+ token,
+ requesting_client_id=registration.client_id,
+ allow_any_client=getattr(registration, "allow_introspection", False),
+ )
+ return JSONResponse(result)
+
+ # -- revocation (RFC 7009) --------------------------------------------
+
+ async def _revoke(self, request: Request) -> Response:
+ form = await request.form()
+ registration = self._authenticate(request, form)
+ if registration is None:
+ return self._error(SecurityException("Invalid client", code="INVALID_CLIENT"))
+ token = str(form.get("token", ""))
+ if token:
+ # RFC 7009 §2.1: only the owning client may revoke the token.
+ await self._server.revoke(token, requesting_client_id=registration.client_id)
+ # RFC 7009 §2.2: the AS responds 200 regardless of whether the token existed.
+ return JSONResponse({})
+
+ # -- JWKS --------------------------------------------------------------
+
+ async def _jwks(self, request: Request) -> Response:
+ return JSONResponse(self._server.jwks())
+
+ # -- helpers -----------------------------------------------------------
+
+ @staticmethod
+ def _client_credentials(request: Request, form: Any) -> tuple[str, str]:
+ """Resolve client credentials from HTTP Basic or form params (post)."""
+ basic = AuthorizationServerEndpoints._basic_auth(request)
+ if basic is not None:
+ return basic
+ return str(form.get("client_id", "")), str(form.get("client_secret", ""))
+
+ def _authenticate(self, request: Request, form: Any) -> ClientRegistration | None:
+ client_id, client_secret = self._client_credentials(request, form)
+ return self._server.authenticate_client(client_id, client_secret)
+
+ @staticmethod
+ def _basic_auth(request: Request) -> tuple[str, str] | None:
+ header = request.headers.get("authorization", "")
+ parts = header.split(" ", 1)
+ if len(parts) != 2 or parts[0].lower() != "basic":
+ return None
+ try:
+ decoded = base64.b64decode(parts[1].strip(), validate=True).decode("utf-8")
+ except (binascii.Error, ValueError, UnicodeDecodeError):
+ return None
+ cid, sep, secret = decoded.partition(":")
+ return (cid, secret) if sep else None
+
+ @staticmethod
+ def _error(exc: SecurityException) -> JSONResponse:
+ code = getattr(exc, "code", "INVALID_REQUEST") or "INVALID_REQUEST"
+ status = 401 if code in _UNAUTHORIZED_ERRORS else 400
+ headers = {"WWW-Authenticate": 'Basic realm="oauth2"'} if status == 401 else None
+ return JSONResponse({"error": code.lower(), "error_description": str(exc)}, status_code=status, headers=headers)
diff --git a/src/pyfly/security/oauth2/login.py b/src/pyfly/security/oauth2/login.py
index ed26ce96..a50927e1 100644
--- a/src/pyfly/security/oauth2/login.py
+++ b/src/pyfly/security/oauth2/login.py
@@ -47,6 +47,16 @@ def _generate_pkce() -> tuple[str, str]:
return verifier, challenge
+def _uses_pkce(registration: Any) -> bool:
+ """Whether PKCE applies to this registration's authorization_code flow.
+
+ PKCE is on by default (RFC 9700 / OAuth 2.1). It is always enforced for a
+ public client (no ``client_secret``) — which has no other defense against
+ authorization-code injection — even if ``use_pkce`` was explicitly disabled.
+ """
+ return bool(getattr(registration, "use_pkce", True)) or not getattr(registration, "client_secret", "")
+
+
class OAuth2LoginHandler:
"""Creates Starlette routes for the OAuth2 authorization_code login flow.
@@ -110,7 +120,7 @@ async def _handle_authorization(self, request: Request) -> Response:
"nonce": nonce,
}
# PKCE (RFC 7636): stash the verifier in the session, send only the S256 challenge.
- if getattr(registration, "use_pkce", False):
+ if _uses_pkce(registration):
verifier, challenge = _generate_pkce()
session.set_attribute(_OAUTH2_PKCE_VERIFIER_KEY, verifier)
params["code_challenge"] = challenge
@@ -168,9 +178,16 @@ async def _handle_callback(self, request: Request) -> Response:
status_code=400,
)
+ # RFC 9207 mix-up defense: validate the issuer that produced this response.
+ # The ``iss`` param is always rejected on mismatch with the registration's
+ # ``issuer_uri``; with ``require_iss`` it must also be present.
+ iss_error = self._validate_iss(registration, request.query_params.get("iss"))
+ if iss_error is not None:
+ return iss_error
+
# PKCE: retrieve and consume the one-time verifier stashed at authorization time.
code_verifier = None
- if getattr(registration, "use_pkce", False):
+ if _uses_pkce(registration):
code_verifier = session.get_attribute(_OAUTH2_PKCE_VERIFIER_KEY)
session.remove_attribute(_OAUTH2_PKCE_VERIFIER_KEY)
@@ -254,6 +271,31 @@ async def _handle_logout(self, request: Request) -> Response:
# Internal helpers
# ------------------------------------------------------------------
+ @staticmethod
+ def _validate_iss(registration: Any, received_iss: str | None) -> Response | None:
+ """Validate the RFC 9207 ``iss`` authorization-response parameter.
+
+ Returns a 400 response on a mismatch, or when ``require_iss`` is set and the
+ parameter is absent; otherwise ``None`` (validation passed).
+ """
+ expected = getattr(registration, "issuer_uri", "") or ""
+ require = getattr(registration, "require_iss", False)
+ if received_iss is None:
+ if require:
+ logger.warning("OAuth2 callback missing required 'iss' parameter (RFC 9207)")
+ return JSONResponse(
+ {"error": "invalid_iss", "message": "Missing required 'iss' parameter"},
+ status_code=400,
+ )
+ return None
+ if expected and received_iss != expected:
+ logger.warning("OAuth2 'iss' mismatch: expected %r, got %r", expected, received_iss)
+ return JSONResponse(
+ {"error": "invalid_iss", "message": "Issuer (iss) does not match the expected provider"},
+ status_code=400,
+ )
+ return None
+
async def _exchange_code(self, registration: Any, code: str, code_verifier: str | None = None) -> dict[str, Any]:
"""Exchange an authorization code for tokens via the token endpoint."""
data = {
diff --git a/src/pyfly/security/oauth2/properties.py b/src/pyfly/security/oauth2/properties.py
index 2bafecf6..5269c292 100644
--- a/src/pyfly/security/oauth2/properties.py
+++ b/src/pyfly/security/oauth2/properties.py
@@ -91,6 +91,12 @@ class ResourceServerProperties:
scope_claim_names: str = "scp,scope"
attribute_claims: str = ""
+ # --- sender-constrained tokens (RFC 9449 DPoP / RFC 8705 mTLS) --------
+ # When true, a token carrying a ``cnf`` claim must be accompanied by proof of
+ # possession (a DPoP proof header, or a client certificate in the mTLS header).
+ enforce_sender_constraints: bool = False
+ mtls_cert_header: str = "x-client-cert"
+
# --- filter -----------------------------------------------------------
exclude_patterns: str = ""
# "anonymous" (default, non-breaking): an invalid/missing token yields an
diff --git a/src/pyfly/security/oauth2/resource_server.py b/src/pyfly/security/oauth2/resource_server.py
index d94ced9e..18e06dbb 100644
--- a/src/pyfly/security/oauth2/resource_server.py
+++ b/src/pyfly/security/oauth2/resource_server.py
@@ -253,50 +253,116 @@ def to_security_context(self, token: str) -> SecurityContext:
payload = self.validate(token)
return self._build_context(payload)
+ def validate_and_context(self, token: str) -> tuple[dict[str, Any], SecurityContext]:
+ """Validate *token* once and return both the raw claims and the context.
+
+ Lets a filter inspect claims (e.g. ``cnf`` for sender-constraining) without
+ validating the signature twice."""
+ payload = self.validate(token)
+ return payload, self._build_context(payload)
+
def _build_context(self, payload: dict[str, Any]) -> SecurityContext:
"""Map a validated *payload* onto a :class:`SecurityContext` per the
configured claim mappings. Subclasses may override for bespoke mapping."""
- m = self._mappings
-
- # Principal: first non-empty principal claim wins.
- user_id: str | None = None
- for claim in m.principal_claims:
- vals = _flatten_strs(_resolve_claim_path(payload, claim))
- if vals:
- user_id = vals[0]
- break
-
- # Authorities/roles: collect across every configured path, de-duplicated
- # (order-preserving), with the optional prefix applied.
- roles: list[str] = []
- seen: set[str] = set()
- for claim in m.authority_claims:
- for raw in _flatten_strs(_resolve_claim_path(payload, claim)):
- value = f"{m.authority_prefix}{raw}" if m.authority_prefix else raw
- if value not in seen:
- seen.add(value)
- roles.append(value)
-
- # Permissions/scopes: scope claims are space-delimited strings or lists.
- permissions: list[str] = []
- perm_seen: set[str] = set()
- for claim in m.scope_claims:
- for raw in _flatten_strs(_resolve_claim_path(payload, claim)):
- for part in raw.split():
- if part and part not in perm_seen:
- perm_seen.add(part)
- permissions.append(part)
-
- # Attributes: copy configured claims verbatim (string-coerced).
- attributes: dict[str, str] = {}
- for claim in m.attribute_claims:
- vals = _flatten_strs(_resolve_claim_path(payload, claim))
- if vals:
- attributes[claim] = vals[0]
-
- return SecurityContext(
- user_id=user_id,
- roles=roles,
- permissions=permissions,
- attributes=attributes,
- )
+ return build_security_context(payload, self._mappings)
+
+
+def build_security_context(payload: dict[str, Any], mappings: ClaimMappings) -> SecurityContext:
+ """Map a token/introspection *payload* onto a :class:`SecurityContext`.
+
+ Shared by :class:`JWKSTokenValidator` and :class:`OpaqueTokenIntrospector` so
+ JWT and opaque-token resource servers map claims identically.
+ """
+ m = mappings
+
+ # Principal: first non-empty principal claim wins.
+ user_id: str | None = None
+ for claim in m.principal_claims:
+ vals = _flatten_strs(_resolve_claim_path(payload, claim))
+ if vals:
+ user_id = vals[0]
+ break
+
+ # Authorities/roles: collect across every configured path, de-duplicated
+ # (order-preserving), with the optional prefix applied.
+ roles: list[str] = []
+ seen: set[str] = set()
+ for claim in m.authority_claims:
+ for raw in _flatten_strs(_resolve_claim_path(payload, claim)):
+ value = f"{m.authority_prefix}{raw}" if m.authority_prefix else raw
+ if value not in seen:
+ seen.add(value)
+ roles.append(value)
+
+ # Permissions/scopes: scope claims are space-delimited strings or lists.
+ permissions: list[str] = []
+ perm_seen: set[str] = set()
+ for claim in m.scope_claims:
+ for raw in _flatten_strs(_resolve_claim_path(payload, claim)):
+ for part in raw.split():
+ if part and part not in perm_seen:
+ perm_seen.add(part)
+ permissions.append(part)
+
+ # Attributes: copy configured claims verbatim (string-coerced).
+ attributes: dict[str, str] = {}
+ for claim in m.attribute_claims:
+ vals = _flatten_strs(_resolve_claim_path(payload, claim))
+ if vals:
+ attributes[claim] = vals[0]
+
+ return SecurityContext(
+ user_id=user_id,
+ roles=roles,
+ permissions=permissions,
+ attributes=attributes,
+ )
+
+
+class OpaqueTokenIntrospector:
+ """Validates opaque access tokens via an RFC 7662 introspection endpoint.
+
+ The resource server posts the token (with its own client credentials) to the
+ authorization server's ``/introspect`` endpoint and maps the returned claims
+ onto a :class:`SecurityContext` using the same :class:`ClaimMappings` as the
+ JWT validator. Use this for opaque (non-JWT) tokens.
+ """
+
+ def __init__(
+ self,
+ introspection_uri: str,
+ *,
+ client_id: str,
+ client_secret: str,
+ claim_mappings: ClaimMappings | None = None,
+ timeout: float = 10.0,
+ ) -> None:
+ self._uri = introspection_uri
+ self._client_id = client_id
+ self._client_secret = client_secret
+ self._mappings = claim_mappings or ClaimMappings()
+ self._timeout = timeout
+
+ def introspect(self, token: str) -> dict[str, Any]:
+ """Return the introspection claims for *token*, or raise if it is inactive."""
+ import httpx
+
+ try:
+ with httpx.Client(timeout=self._timeout) as client:
+ resp = client.post(
+ self._uri,
+ data={"token": token, "token_type_hint": "access_token"},
+ auth=(self._client_id, self._client_secret),
+ headers={"Accept": "application/json"},
+ )
+ except httpx.HTTPError as exc:
+ raise SecurityException(f"Token introspection request failed: {exc}", code="INVALID_TOKEN") from exc
+ if resp.status_code != 200:
+ raise SecurityException(f"Token introspection failed (HTTP {resp.status_code})", code="INVALID_TOKEN")
+ payload: dict[str, Any] = resp.json()
+ if not payload.get("active"):
+ raise SecurityException("Token is not active", code="INVALID_TOKEN")
+ return payload
+
+ def to_security_context(self, token: str) -> SecurityContext:
+ return build_security_context(self.introspect(token), self._mappings)
diff --git a/src/pyfly/security/password.py b/src/pyfly/security/password.py
index e8f50d48..df506416 100644
--- a/src/pyfly/security/password.py
+++ b/src/pyfly/security/password.py
@@ -11,10 +11,20 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
-"""Password encoding port and bcrypt adapter."""
+"""Password encoding port and adapters.
+
+Provides a :class:`PasswordEncoder` port and several adapters — bcrypt, PBKDF2,
+scrypt, Argon2 — plus a :class:`DelegatingPasswordEncoder` that prefixes hashes
+with a ``{id}`` so the algorithm can be migrated over time (Spring Security
+parity with ``DelegatingPasswordEncoder`` / ``PasswordEncoderFactories``).
+"""
from __future__ import annotations
+import base64
+import hashlib
+import hmac
+import secrets
from typing import Protocol, runtime_checkable
import bcrypt as _bcrypt
@@ -50,7 +60,177 @@ def hash(self, raw_password: str) -> str:
def verify(self, raw_password: str, hashed_password: str) -> bool:
"""Verify a raw password against a bcrypt hash."""
- return _bcrypt.checkpw(
- raw_password.encode("utf-8"),
- hashed_password.encode("utf-8"),
+ try:
+ return _bcrypt.checkpw(
+ raw_password.encode("utf-8"),
+ hashed_password.encode("utf-8"),
+ )
+ except (ValueError, TypeError):
+ # Malformed / non-bcrypt stored value — treat as a non-match.
+ return False
+
+
+class Pbkdf2PasswordEncoder:
+ """PasswordEncoder using PBKDF2-HMAC (stdlib ``hashlib``).
+
+ Produces a self-describing string ``$$$``.
+ PBKDF2 is FIPS-friendly; defaults to 600k SHA-256 iterations (OWASP 2023).
+ """
+
+ def __init__(self, *, iterations: int = 600_000, salt_bytes: int = 16, algorithm: str = "sha256") -> None:
+ self._iterations = iterations
+ self._salt_bytes = salt_bytes
+ self._algorithm = algorithm
+
+ def hash(self, raw_password: str) -> str:
+ salt = secrets.token_bytes(self._salt_bytes)
+ digest = hashlib.pbkdf2_hmac(self._algorithm, raw_password.encode("utf-8"), salt, self._iterations)
+ return (
+ f"{self._algorithm}${self._iterations}$"
+ f"{base64.b64encode(salt).decode('ascii')}${base64.b64encode(digest).decode('ascii')}"
+ )
+
+ def verify(self, raw_password: str, hashed_password: str) -> bool:
+ try:
+ algorithm, iterations_s, salt_b64, digest_b64 = hashed_password.split("$")
+ iterations = int(iterations_s)
+ salt = base64.b64decode(salt_b64)
+ expected = base64.b64decode(digest_b64)
+ except (ValueError, TypeError):
+ return False
+ actual = hashlib.pbkdf2_hmac(algorithm, raw_password.encode("utf-8"), salt, iterations, dklen=len(expected))
+ return hmac.compare_digest(actual, expected)
+
+
+class ScryptPasswordEncoder:
+ """PasswordEncoder using scrypt (stdlib ``hashlib.scrypt``).
+
+ Produces ``$$$$``. Memory-hard; defaults follow
+ common interactive-login parameters (n=2**14, r=8, p=1).
+ """
+
+ def __init__(self, *, n: int = 2**14, r: int = 8, p: int = 1, salt_bytes: int = 16, dklen: int = 32) -> None:
+ self._n = n
+ self._r = r
+ self._p = p
+ self._salt_bytes = salt_bytes
+ self._dklen = dklen
+
+ def hash(self, raw_password: str) -> str:
+ salt = secrets.token_bytes(self._salt_bytes)
+ digest = hashlib.scrypt(
+ raw_password.encode("utf-8"), salt=salt, n=self._n, r=self._r, p=self._p, dklen=self._dklen
+ )
+ return (
+ f"{self._n}${self._r}${self._p}$"
+ f"{base64.b64encode(salt).decode('ascii')}${base64.b64encode(digest).decode('ascii')}"
+ )
+
+ def verify(self, raw_password: str, hashed_password: str) -> bool:
+ try:
+ n_s, r_s, p_s, salt_b64, digest_b64 = hashed_password.split("$")
+ n, r, p = int(n_s), int(r_s), int(p_s)
+ salt = base64.b64decode(salt_b64)
+ expected = base64.b64decode(digest_b64)
+ except (ValueError, TypeError):
+ return False
+ try:
+ actual = hashlib.scrypt(raw_password.encode("utf-8"), salt=salt, n=n, r=r, p=p, dklen=len(expected))
+ except ValueError:
+ return False
+ return hmac.compare_digest(actual, expected)
+
+
+class Argon2PasswordEncoder:
+ """PasswordEncoder using Argon2id (OWASP-preferred). Requires ``argon2-cffi``.
+
+ The dependency is imported lazily so the rest of the security module works
+ without it; install with ``pip install pyfly[argon2]`` to use this encoder.
+ """
+
+ def __init__(self, *, time_cost: int = 3, memory_cost: int = 65536, parallelism: int = 4) -> None:
+ self._time_cost = time_cost
+ self._memory_cost = memory_cost
+ self._parallelism = parallelism
+
+ def _hasher(self) -> object:
+ try:
+ from argon2 import PasswordHasher # type: ignore[import-not-found, unused-ignore]
+ except ImportError as exc: # pragma: no cover - exercised only without argon2-cffi
+ raise ImportError("Argon2PasswordEncoder requires argon2-cffi — `pip install pyfly[argon2]`") from exc
+ return PasswordHasher(time_cost=self._time_cost, memory_cost=self._memory_cost, parallelism=self._parallelism)
+
+ def hash(self, raw_password: str) -> str:
+ return str(self._hasher().hash(raw_password)) # type: ignore[attr-defined]
+
+ def verify(self, raw_password: str, hashed_password: str) -> bool:
+ from argon2.exceptions import ( # type: ignore[import-not-found, unused-ignore]
+ VerificationError,
+ VerifyMismatchError,
)
+
+ try:
+ return bool(self._hasher().verify(hashed_password, raw_password)) # type: ignore[attr-defined]
+ except (VerifyMismatchError, VerificationError):
+ return False
+
+
+class DelegatingPasswordEncoder:
+ """Password encoder that prefixes hashes with ``{id}`` and delegates by id.
+
+ Spring Security parity (``DelegatingPasswordEncoder``): :meth:`hash` produces
+ ``{}`` using the default encoder; :meth:`verify`
+ reads the ``{id}`` prefix and dispatches to the matching encoder. A stored
+ value with an unknown or missing prefix never matches. :meth:`upgrade_encoding`
+ reports whether a stored hash should be re-hashed with the current default —
+ enabling transparent on-login migration between algorithms.
+ """
+
+ def __init__(self, encoders: dict[str, PasswordEncoder], encoding_id: str) -> None:
+ if encoding_id not in encoders:
+ raise ValueError(f"encoding_id {encoding_id!r} is not present in the encoders map")
+ self._encoders = dict(encoders)
+ self._encoding_id = encoding_id
+
+ @staticmethod
+ def _split(stored: str) -> tuple[str | None, str]:
+ """Return ``(id, remainder)`` for a ``{id}...`` value, or ``(None, stored)``."""
+ if stored.startswith("{"):
+ end = stored.find("}")
+ if end > 0:
+ return stored[1:end], stored[end + 1 :]
+ return None, stored
+
+ def hash(self, raw_password: str) -> str:
+ inner = self._encoders[self._encoding_id].hash(raw_password)
+ return f"{{{self._encoding_id}}}{inner}"
+
+ def verify(self, raw_password: str, hashed_password: str) -> bool:
+ encoding_id, inner = self._split(hashed_password)
+ encoder = self._encoders.get(encoding_id) if encoding_id is not None else None
+ if encoder is None:
+ return False
+ return encoder.verify(raw_password, inner)
+
+ def upgrade_encoding(self, hashed_password: str) -> bool:
+ """Whether *hashed_password* should be re-hashed with the current default."""
+ encoding_id, _ = self._split(hashed_password)
+ return encoding_id != self._encoding_id
+
+
+def create_delegating_password_encoder(*, bcrypt_rounds: int = 12) -> DelegatingPasswordEncoder:
+ """Build a :class:`DelegatingPasswordEncoder` with bcrypt as the default id.
+
+ Mirrors Spring's ``PasswordEncoderFactories.createDelegatingPasswordEncoder()``:
+ new hashes use bcrypt (``{bcrypt}``), while ``{pbkdf2}``, ``{scrypt}`` and
+ ``{argon2}`` hashes are still recognised for verification and migration.
+ """
+ return DelegatingPasswordEncoder(
+ {
+ "bcrypt": BcryptPasswordEncoder(rounds=bcrypt_rounds),
+ "pbkdf2": Pbkdf2PasswordEncoder(),
+ "scrypt": ScryptPasswordEncoder(),
+ "argon2": Argon2PasswordEncoder(),
+ },
+ encoding_id="bcrypt",
+ )
diff --git a/src/pyfly/security/permission.py b/src/pyfly/security/permission.py
new file mode 100644
index 00000000..0816aa84
--- /dev/null
+++ b/src/pyfly/security/permission.py
@@ -0,0 +1,44 @@
+# Copyright 2026 Firefly Software Foundation.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""PermissionEvaluator — ACL-style ``hasPermission`` SPI (Spring parity).
+
+Install one via :func:`pyfly.security.expression.set_permission_evaluator` to back
+``hasPermission(target, 'perm')`` / ``hasPermission(id, 'Type', 'perm')`` method
+-security expressions with domain-object permission checks.
+"""
+
+from __future__ import annotations
+
+from typing import Any, Protocol, runtime_checkable
+
+
+@runtime_checkable
+class PermissionEvaluator(Protocol):
+ """Decides whether the current principal holds *permission* on a target object."""
+
+ def has_permission(
+ self,
+ context: Any,
+ target: Any,
+ permission: str,
+ *,
+ target_type: str | None = None,
+ ) -> bool:
+ """Return whether the principal (in *context*) has *permission* on *target*.
+
+ *target* is the domain object (2-arg form) or its identifier (3-arg form,
+ where *target_type* names the object type). *context* is the active
+ :class:`~pyfly.security.context.SecurityContext`.
+ """
+ ...
diff --git a/src/pyfly/security/user_details.py b/src/pyfly/security/user_details.py
new file mode 100644
index 00000000..7905de32
--- /dev/null
+++ b/src/pyfly/security/user_details.py
@@ -0,0 +1,55 @@
+# Copyright 2026 Firefly Software Foundation.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""UserDetails / UserDetailsService — the credential-lookup SPI.
+
+Spring Security parity: a :class:`UserDetailsService` resolves a username to a
+:class:`UserDetails` (a stored password hash plus authorities), which the HTTP
+Basic / form-login filters verify against a :class:`~pyfly.security.password.PasswordEncoder`.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from typing import Protocol, runtime_checkable
+
+
+@dataclass(frozen=True)
+class UserDetails:
+ """A resolved principal: a stored credential plus granted authorities."""
+
+ username: str
+ password_hash: str
+ roles: list[str] = field(default_factory=list)
+ permissions: list[str] = field(default_factory=list)
+ enabled: bool = True
+
+
+@runtime_checkable
+class UserDetailsService(Protocol):
+ """Port that resolves a username to its :class:`UserDetails`, or ``None``."""
+
+ async def load_user_by_username(self, username: str) -> UserDetails | None: ...
+
+
+class InMemoryUserDetailsService:
+ """A :class:`UserDetailsService` backed by an in-memory dict (dev / testing)."""
+
+ def __init__(self, *users: UserDetails) -> None:
+ self._users: dict[str, UserDetails] = {u.username: u for u in users}
+
+ async def load_user_by_username(self, username: str) -> UserDetails | None:
+ return self._users.get(username)
+
+ def add(self, user: UserDetails) -> None:
+ self._users[user.username] = user
diff --git a/src/pyfly/web/adapters/starlette/filters/csrf_filter.py b/src/pyfly/web/adapters/starlette/filters/csrf_filter.py
index 0553b081..2916f7fe 100644
--- a/src/pyfly/web/adapters/starlette/filters/csrf_filter.py
+++ b/src/pyfly/web/adapters/starlette/filters/csrf_filter.py
@@ -73,6 +73,15 @@ class CsrfFilter(OncePerRequestFilter):
exclude_patterns = ["/actuator/*", "/health", "/ready"]
+ def __init__(self, *, cookie_gated: bool = True) -> None:
+ # ``cookie_gated`` (default): only enforce CSRF on unsafe requests that
+ # carry cookies — i.e. requests with ambient authority a cross-site forgery
+ # could abuse. A request with no cookies (a stateless API client) has no
+ # CSRF surface and is exempt, so CSRF can be on by default without breaking
+ # token/stateless clients. Set ``cookie_gated=False`` for strict enforcement
+ # of every unsafe request regardless of cookies.
+ self._cookie_gated = cookie_gated
+
async def do_filter(self, request: Any, call_next: CallNext) -> Any:
method: str = request.method
@@ -91,6 +100,13 @@ async def do_filter(self, request: Any, call_next: CallNext) -> Any:
if auth_header and auth_header.startswith("Bearer "):
return await call_next(request)
+ # -----------------------------------------------------------------
+ # Cookie-gated exemption — no cookies means no ambient authority for a
+ # cross-site request to abuse, so there is nothing to protect.
+ # -----------------------------------------------------------------
+ if self._cookie_gated and not request.cookies:
+ return await call_next(request)
+
# -----------------------------------------------------------------
# Unsafe methods — validate double-submit cookie.
# -----------------------------------------------------------------
diff --git a/src/pyfly/web/adapters/starlette/filters/form_login_filter.py b/src/pyfly/web/adapters/starlette/filters/form_login_filter.py
new file mode 100644
index 00000000..8cd68bdf
--- /dev/null
+++ b/src/pyfly/web/adapters/starlette/filters/form_login_filter.py
@@ -0,0 +1,103 @@
+# Copyright 2026 Firefly Software Foundation.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Form-login filter (Spring ``formLogin``).
+
+Processes a POST of username/password to the login URL, authenticates via an
+:class:`~pyfly.security.authentication.ProviderManager`, and on success rotates
+the session id (fixation defense) and stores the :class:`SecurityContext` in the
+session — where :class:`OAuth2SessionSecurityFilter` restores it on later
+requests. Browser (redirect) and API (JSON) responses are both supported.
+"""
+
+from __future__ import annotations
+
+import logging
+
+from starlette.requests import Request
+from starlette.responses import JSONResponse, RedirectResponse, Response
+
+from pyfly.container.ordering import HIGHEST_PRECEDENCE
+from pyfly.security.authentication import Authentication, AuthenticationException, ProviderManager
+from pyfly.web.filters import OncePerRequestFilter
+from pyfly.web.ports.filter import CallNext
+
+logger = logging.getLogger(__name__)
+
+_SECURITY_CONTEXT_KEY = "SECURITY_CONTEXT"
+
+
+class FormLoginFilter(OncePerRequestFilter):
+ """Authenticates a username/password form POST and establishes a session.
+
+ Runs at ``HIGHEST_PRECEDENCE + 230`` — after the session-restoring filter
+ (``+225``) so a successful login overrides any prior anonymous context.
+ """
+
+ __pyfly_order__ = HIGHEST_PRECEDENCE + 230
+
+ def __init__(
+ self,
+ authentication_manager: ProviderManager,
+ *,
+ login_url: str = "/login",
+ username_param: str = "username",
+ password_param: str = "password",
+ success_url: str = "/",
+ failure_url: str = "/login?error",
+ use_redirect: bool = True,
+ ) -> None:
+ self._manager = authentication_manager
+ self._login_url = login_url
+ self._username_param = username_param
+ self._password_param = password_param
+ self._success_url = success_url
+ self._failure_url = failure_url
+ self._use_redirect = use_redirect
+
+ async def do_filter(self, request: Request, call_next: CallNext) -> Response:
+ if request.method == "POST" and request.url.path == self._login_url:
+ return await self._attempt_login(request)
+ return await call_next(request) # type: ignore[no-any-return]
+
+ async def _attempt_login(self, request: Request) -> Response:
+ form = await request.form()
+ username = str(form.get(self._username_param, "") or "")
+ password = str(form.get(self._password_param, "") or "")
+
+ try:
+ result = await self._manager.authenticate(Authentication(principal=username, credentials=password))
+ except AuthenticationException:
+ logger.warning("Form login failed for user %r", username)
+ return self._failure()
+
+ context = result.to_security_context()
+ session = getattr(getattr(request, "state", None), "session", None)
+ if session is not None:
+ # Rotate the session id on authentication to prevent session fixation,
+ # then bind the authenticated context to the (new) session.
+ session.rotate_id()
+ session.set_attribute(_SECURITY_CONTEXT_KEY, context)
+ request.state.security_context = context
+ logger.info("Form login successful for user: %s", context.user_id)
+ return self._success()
+
+ def _success(self) -> Response:
+ if self._use_redirect:
+ return RedirectResponse(url=self._success_url, status_code=302)
+ return JSONResponse({"authenticated": True})
+
+ def _failure(self) -> Response:
+ if self._use_redirect:
+ return RedirectResponse(url=self._failure_url, status_code=302)
+ return JSONResponse({"error": "invalid_credentials"}, status_code=401)
diff --git a/src/pyfly/web/adapters/starlette/filters/http_basic_filter.py b/src/pyfly/web/adapters/starlette/filters/http_basic_filter.py
new file mode 100644
index 00000000..9f55207f
--- /dev/null
+++ b/src/pyfly/web/adapters/starlette/filters/http_basic_filter.py
@@ -0,0 +1,138 @@
+# Copyright 2026 Firefly Software Foundation.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""HTTP Basic authentication filter (RFC 7617).
+
+Parses an ``Authorization: Basic`` header, resolves the user via a
+:class:`~pyfly.security.user_details.UserDetailsService`, verifies the password
+with a :class:`~pyfly.security.password.PasswordEncoder`, and populates the
+request :class:`SecurityContext`.
+
+``error_mode`` mirrors the OAuth2 resource-server filter:
+
+* ``"anonymous"`` (default): bad/missing credentials yield an anonymous context
+ and the request proceeds — the ``HttpSecurity`` gate decides.
+* ``"401"``: present-but-invalid credentials are rejected here with
+ ``401 Unauthorized`` and a ``WWW-Authenticate: Basic realm="…"`` challenge.
+ Missing credentials still fall through to the gate.
+"""
+
+from __future__ import annotations
+
+import base64
+import binascii
+import logging
+from typing import cast
+
+from anyio import to_thread
+from starlette.requests import Request
+from starlette.responses import JSONResponse, Response
+
+from pyfly.container.ordering import HIGHEST_PRECEDENCE, order
+from pyfly.context.request_context import RequestContext
+from pyfly.security.context import SecurityContext
+from pyfly.security.password import PasswordEncoder
+from pyfly.security.user_details import UserDetailsService
+from pyfly.web.filters import OncePerRequestFilter
+from pyfly.web.ports.filter import CallNext
+
+logger = logging.getLogger(__name__)
+
+ERROR_MODE_ANONYMOUS = "anonymous"
+ERROR_MODE_401 = "401"
+
+
+@order(HIGHEST_PRECEDENCE + 215)
+class HttpBasicAuthenticationFilter(OncePerRequestFilter):
+ """Authenticates ``Authorization: Basic`` credentials against a UserDetailsService.
+
+ Ordered just before the symmetric JWT ``SecurityFilter`` (``+220``) so it can
+ establish a context for credential-based clients while leaving token-based
+ auth to the later filters when no Basic header is present.
+ """
+
+ def __init__(
+ self,
+ user_details_service: UserDetailsService,
+ password_encoder: PasswordEncoder,
+ *,
+ realm: str = "Realm",
+ error_mode: str = ERROR_MODE_ANONYMOUS,
+ ) -> None:
+ self._users = user_details_service
+ self._encoder = password_encoder
+ self._realm = realm
+ self._error_mode = error_mode if error_mode in (ERROR_MODE_ANONYMOUS, ERROR_MODE_401) else ERROR_MODE_ANONYMOUS
+
+ async def do_filter(self, request: Request, call_next: CallNext) -> Response:
+ credentials = self._extract_basic(request.headers.get("authorization", ""))
+
+ if credentials is None:
+ # No Basic credentials presented — leave any existing context alone and
+ # default to anonymous so downstream filters/handlers always have one.
+ if not hasattr(request.state, "security_context"):
+ request.state.security_context = SecurityContext.anonymous()
+ return cast(Response, await call_next(request))
+
+ username, password = credentials
+ context = await self._authenticate(username, password)
+
+ if context is None:
+ logger.warning("HTTP Basic authentication failed for user %r", username)
+ if self._error_mode == ERROR_MODE_401:
+ return self._challenge()
+ context = SecurityContext.anonymous()
+
+ request.state.security_context = context
+ req_ctx = RequestContext.current()
+ if req_ctx is not None:
+ req_ctx.security_context = context
+ return cast(Response, await call_next(request))
+
+ async def _authenticate(self, username: str, password: str) -> SecurityContext | None:
+ user = await self._users.load_user_by_username(username)
+ if user is None or not user.enabled:
+ return None
+ # bcrypt/argon2 verification is CPU-bound; offload so we never block the loop.
+ ok = await to_thread.run_sync(self._encoder.verify, password, user.password_hash)
+ if not ok:
+ return None
+ return SecurityContext(user_id=user.username, roles=list(user.roles), permissions=list(user.permissions))
+
+ @staticmethod
+ def _extract_basic(auth_header: str) -> tuple[str, str] | None:
+ """Return ``(username, password)`` from a Basic header, or ``None``.
+
+ Returns ``("", "")``-style failures as ``None`` only for a *missing* or
+ *non-Basic* header; a malformed Basic payload raises through to a 401 by
+ returning a sentinel the caller treats as an auth failure.
+ """
+ parts = auth_header.split(" ", 1)
+ if len(parts) != 2 or parts[0].lower() != "basic" or not parts[1].strip():
+ return None
+ try:
+ decoded = base64.b64decode(parts[1].strip(), validate=True).decode("utf-8")
+ except (binascii.Error, ValueError, UnicodeDecodeError):
+ # Malformed credentials — treat as a (present) failed attempt.
+ return ("\x00invalid", "")
+ username, sep, password = decoded.partition(":")
+ if not sep:
+ return ("\x00invalid", "")
+ return (username, password)
+
+ def _challenge(self) -> Response:
+ return JSONResponse(
+ {"error": "invalid_credentials", "error_description": "Authentication failed."},
+ status_code=401,
+ headers={"WWW-Authenticate": f'Basic realm="{self._realm}"'},
+ )
diff --git a/src/pyfly/web/adapters/starlette/filters/http_security_filter.py b/src/pyfly/web/adapters/starlette/filters/http_security_filter.py
index 118e3341..f4db26bd 100644
--- a/src/pyfly/web/adapters/starlette/filters/http_security_filter.py
+++ b/src/pyfly/web/adapters/starlette/filters/http_security_filter.py
@@ -85,9 +85,14 @@ async def do_filter(self, request: Request, call_next: CallNext) -> Response:
path: str = request.url.path
security_context: SecurityContext = getattr(request.state, "security_context", SecurityContext.anonymous())
+ method: str = request.method.upper()
for security_rule in self._rules:
if not _matches(path, security_rule.patterns):
continue
+ # A rule scoped to specific HTTP methods only applies to those methods;
+ # an empty method list matches any method.
+ if security_rule.methods and method not in security_rule.methods:
+ continue
rule = security_rule.rule
rule_type = rule.rule_type
diff --git a/src/pyfly/web/adapters/starlette/filters/logout_filter.py b/src/pyfly/web/adapters/starlette/filters/logout_filter.py
new file mode 100644
index 00000000..7da684e3
--- /dev/null
+++ b/src/pyfly/web/adapters/starlette/filters/logout_filter.py
@@ -0,0 +1,82 @@
+# Copyright 2026 Firefly Software Foundation.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Generic logout filter (Spring ``logout`` / ``LogoutConfigurer``).
+
+Handles a POST to the logout URL by invalidating the HTTP session, clearing the
+security context, and deleting configured cookies — independent of OAuth2. Browser
+(redirect) and API (204) responses are both supported.
+"""
+
+from __future__ import annotations
+
+import logging
+from collections.abc import Sequence
+
+from starlette.requests import Request
+from starlette.responses import RedirectResponse, Response
+
+from pyfly.container.ordering import HIGHEST_PRECEDENCE
+from pyfly.web.filters import OncePerRequestFilter
+from pyfly.web.ports.filter import CallNext
+
+logger = logging.getLogger(__name__)
+
+_SECURITY_CONTEXT_KEY = "SECURITY_CONTEXT"
+
+
+class LogoutFilter(OncePerRequestFilter):
+ """Invalidates the session on a POST to the logout URL.
+
+ Runs at ``HIGHEST_PRECEDENCE + 235`` (after form login). Configure the URL,
+ success URL, response mode, and cookies to clear.
+ """
+
+ __pyfly_order__ = HIGHEST_PRECEDENCE + 235
+
+ def __init__(
+ self,
+ *,
+ logout_url: str = "/logout",
+ logout_success_url: str = "/login?logout",
+ delete_cookies: Sequence[str] = (),
+ use_redirect: bool = True,
+ ) -> None:
+ self._logout_url = logout_url
+ self._logout_success_url = logout_success_url
+ self._delete_cookies = list(delete_cookies)
+ self._use_redirect = use_redirect
+
+ async def do_filter(self, request: Request, call_next: CallNext) -> Response:
+ if request.method == "POST" and request.url.path == self._logout_url:
+ return self._logout(request)
+ return await call_next(request) # type: ignore[no-any-return]
+
+ def _logout(self, request: Request) -> Response:
+ session = getattr(getattr(request, "state", None), "session", None)
+ if session is not None:
+ session.set_attribute(_SECURITY_CONTEXT_KEY, None)
+ session.invalidate()
+ if hasattr(request, "state"):
+ from pyfly.security.context import SecurityContext
+
+ request.state.security_context = SecurityContext.anonymous()
+ response: Response
+ if self._use_redirect:
+ response = RedirectResponse(url=self._logout_success_url, status_code=302)
+ else:
+ response = Response(status_code=204)
+ for cookie in self._delete_cookies:
+ response.delete_cookie(cookie, path="/")
+ logger.info("Logout processed for path %s", request.url.path)
+ return response
diff --git a/src/pyfly/web/adapters/starlette/filters/oauth2_resource_filter.py b/src/pyfly/web/adapters/starlette/filters/oauth2_resource_filter.py
index 8c42c079..2d1894d2 100644
--- a/src/pyfly/web/adapters/starlette/filters/oauth2_resource_filter.py
+++ b/src/pyfly/web/adapters/starlette/filters/oauth2_resource_filter.py
@@ -17,7 +17,7 @@
import logging
from collections.abc import Sequence
-from typing import cast
+from typing import Any, cast
from anyio import to_thread
from starlette.requests import Request
@@ -70,23 +70,37 @@ def __init__(
exclude_patterns: Sequence[str] = (),
*,
error_mode: str = ERROR_MODE_ANONYMOUS,
+ enforce_sender_constraints: bool = False,
+ dpop_validator: Any = None,
+ mtls_cert_header: str = "x-client-cert",
) -> None:
self._token_validator = token_validator
self.exclude_patterns = list(exclude_patterns)
self._error_mode = error_mode if error_mode in (ERROR_MODE_ANONYMOUS, ERROR_MODE_401) else ERROR_MODE_ANONYMOUS
+ self._enforce_sc = enforce_sender_constraints
+ self._dpop_validator = dpop_validator
+ self._mtls_cert_header = mtls_cert_header
async def do_filter(self, request: Request, call_next: CallNext) -> Response:
- token = self._extract_bearer(request.headers.get("authorization", ""))
+ token = self._extract_token(request.headers.get("authorization", ""))
if token is not None:
try:
# Offload to a worker thread: JWKS key lookup may do blocking
# urllib I/O on a cache miss, which would otherwise stall the loop.
- security_context = await to_thread.run_sync(self._token_validator.to_security_context, token)
+ if self._enforce_sc:
+ payload, security_context = await to_thread.run_sync(
+ self._token_validator.validate_and_context, token
+ )
+ # Sender-constrained tokens (RFC 9449 DPoP / RFC 8705 mTLS) must be
+ # accompanied by proof of possession; a stolen token alone is useless.
+ self._enforce_sender_constraint(request, payload, token)
+ else:
+ security_context = await to_thread.run_sync(self._token_validator.to_security_context, token)
except SecurityException:
# A token was presented but failed validation (bad signature,
- # expired, wrong iss/aud, unknown kid, ...).
- logger.warning("OAuth2 bearer token rejected (invalid_token)")
+ # expired, wrong iss/aud, unknown kid, failed proof-of-possession).
+ logger.warning("OAuth2 token rejected (invalid_token)")
if self._error_mode == ERROR_MODE_401:
return self._invalid_token_response()
security_context = SecurityContext.anonymous()
@@ -100,16 +114,42 @@ async def do_filter(self, request: Request, call_next: CallNext) -> Response:
req_ctx.security_context = security_context
return cast(Response, await call_next(request))
+ def _enforce_sender_constraint(self, request: Request, payload: dict[str, Any], token: str) -> None:
+ """Enforce DPoP/mTLS proof-of-possession when the token carries a ``cnf`` claim."""
+ cnf = payload.get("cnf")
+ if not isinstance(cnf, dict):
+ return # plain bearer token — nothing to enforce
+ if "jkt" in cnf:
+ from urllib.parse import urlsplit, urlunsplit
+
+ from pyfly.security.oauth2.dpop import DPoPProofValidator, confirm_dpop_binding
+
+ proof = request.headers.get("dpop")
+ if not proof:
+ raise SecurityException("DPoP proof required for this token", code="INVALID_TOKEN")
+ validator = self._dpop_validator or DPoPProofValidator()
+ parts = urlsplit(str(request.url))
+ http_url = urlunsplit((parts.scheme, parts.netloc, parts.path, "", ""))
+ jkt = validator.validate(proof, http_method=request.method, http_url=http_url, access_token=token)
+ confirm_dpop_binding(payload, jkt)
+ elif "x5t#S256" in cnf:
+ from urllib.parse import unquote
+
+ from pyfly.security.oauth2.dpop import confirm_mtls_binding
+
+ cert = request.headers.get(self._mtls_cert_header)
+ if not cert:
+ raise SecurityException("Client certificate required for this token", code="INVALID_TOKEN")
+ confirm_mtls_binding(payload, unquote(cert))
+
@staticmethod
- def _extract_bearer(auth_header: str) -> str | None:
- """Return the token from an ``Authorization`` header, or ``None``.
+ def _extract_token(auth_header: str) -> str | None:
+ """Return the token from a ``Bearer`` or ``DPoP`` ``Authorization`` header.
- The auth scheme is matched case-insensitively (RFC 7235 §2.1: the scheme
- is a case-insensitive token), so ``Bearer``, ``bearer`` and ``BEARER``
- are all accepted.
+ The auth scheme is matched case-insensitively (RFC 7235 §2.1).
"""
parts = auth_header.split(" ", 1)
- if len(parts) == 2 and parts[0].lower() == "bearer" and parts[1].strip():
+ if len(parts) == 2 and parts[0].lower() in ("bearer", "dpop") and parts[1].strip():
return parts[1].strip()
return None
diff --git a/src/pyfly/web/adapters/starlette/filters/switch_user_filter.py b/src/pyfly/web/adapters/starlette/filters/switch_user_filter.py
new file mode 100644
index 00000000..f5862204
--- /dev/null
+++ b/src/pyfly/web/adapters/starlette/filters/switch_user_filter.py
@@ -0,0 +1,124 @@
+# Copyright 2026 Firefly Software Foundation.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""switch-user / run-as impersonation (Spring ``SwitchUserFilter``).
+
+An authorized principal (holding ``switch_authority``) may impersonate another
+user by visiting the switch URL; the original principal is stashed in the session
+and restored at the exit URL. While impersonating, the session context carries the
+:data:`PREVIOUS_PRINCIPAL_ROLE` marker so the application can detect run-as.
+"""
+
+from __future__ import annotations
+
+import logging
+from typing import cast
+
+from starlette.requests import Request
+from starlette.responses import JSONResponse, RedirectResponse, Response
+
+from pyfly.container.ordering import HIGHEST_PRECEDENCE
+from pyfly.security.context import SecurityContext
+from pyfly.security.user_details import UserDetailsService
+from pyfly.web.filters import OncePerRequestFilter
+from pyfly.web.ports.filter import CallNext
+
+logger = logging.getLogger(__name__)
+
+_SECURITY_CONTEXT_KEY = "SECURITY_CONTEXT"
+_ORIGINAL_CONTEXT_KEY = "SWITCH_USER_ORIGINAL"
+
+#: Authority granted to an impersonated context so the app can detect run-as
+#: and offer an "exit" action (cf. Spring's ``ROLE_PREVIOUS_ADMINISTRATOR``).
+PREVIOUS_PRINCIPAL_ROLE = "PREVIOUS_ADMINISTRATOR"
+
+
+class SwitchUserFilter(OncePerRequestFilter):
+ """Lets an authorized principal impersonate another user, and switch back.
+
+ Runs at ``HIGHEST_PRECEDENCE + 232`` (after form login, before logout).
+ """
+
+ __pyfly_order__ = HIGHEST_PRECEDENCE + 232
+
+ def __init__(
+ self,
+ user_details_service: UserDetailsService,
+ *,
+ switch_url: str = "/login/impersonate",
+ exit_url: str = "/logout/impersonate",
+ username_param: str = "username",
+ switch_authority: str = "ADMIN",
+ success_url: str = "/",
+ ) -> None:
+ self._users = user_details_service
+ self._switch_url = switch_url
+ self._exit_url = exit_url
+ self._username_param = username_param
+ self._switch_authority = switch_authority
+ self._success_url = success_url
+
+ async def do_filter(self, request: Request, call_next: CallNext) -> Response:
+ path = request.url.path
+ if path == self._switch_url:
+ return await self._switch(request)
+ if path == self._exit_url:
+ return self._exit(request)
+ return cast(Response, await call_next(request))
+
+ def _current_context(self, request: Request) -> SecurityContext | None:
+ session = getattr(getattr(request, "state", None), "session", None)
+ if session is not None:
+ stored = session.get_attribute(_SECURITY_CONTEXT_KEY)
+ if isinstance(stored, SecurityContext):
+ return stored
+ ctx = getattr(getattr(request, "state", None), "security_context", None)
+ return ctx if isinstance(ctx, SecurityContext) else None
+
+ async def _switch(self, request: Request) -> Response:
+ current = self._current_context(request)
+ if current is None or not current.is_authenticated:
+ return JSONResponse({"error": "authentication_required"}, status_code=401)
+ if not (current.has_role(self._switch_authority) or current.has_permission(self._switch_authority)):
+ return JSONResponse({"error": "forbidden"}, status_code=403)
+
+ target_username = request.query_params.get(self._username_param, "")
+ user = await self._users.load_user_by_username(target_username) if target_username else None
+ if user is None or not user.enabled:
+ return JSONResponse({"error": "user_not_found"}, status_code=404)
+
+ impersonated = SecurityContext(
+ user_id=user.username,
+ roles=[*user.roles, PREVIOUS_PRINCIPAL_ROLE],
+ permissions=list(user.permissions),
+ attributes={"switch_user_original": current.user_id or ""},
+ )
+ session = request.state.session
+ session.set_attribute(_ORIGINAL_CONTEXT_KEY, current)
+ session.set_attribute(_SECURITY_CONTEXT_KEY, impersonated)
+ request.state.security_context = impersonated
+ logger.info("User %s is now impersonating %s", current.user_id, user.username)
+ return RedirectResponse(url=self._success_url, status_code=302)
+
+ def _exit(self, request: Request) -> Response:
+ session = getattr(getattr(request, "state", None), "session", None)
+ if session is None:
+ return JSONResponse({"error": "not_impersonating"}, status_code=400)
+ original = session.get_attribute(_ORIGINAL_CONTEXT_KEY)
+ if not isinstance(original, SecurityContext):
+ return JSONResponse({"error": "not_impersonating"}, status_code=400)
+ session.set_attribute(_SECURITY_CONTEXT_KEY, original)
+ session.remove_attribute(_ORIGINAL_CONTEXT_KEY)
+ request.state.security_context = original
+ logger.info("Impersonation ended; restored principal %s", original.user_id)
+ return RedirectResponse(url=self._success_url, status_code=302)
diff --git a/src/pyfly/web/adapters/starlette/filters/x509_filter.py b/src/pyfly/web/adapters/starlette/filters/x509_filter.py
new file mode 100644
index 00000000..aaadc31a
--- /dev/null
+++ b/src/pyfly/web/adapters/starlette/filters/x509_filter.py
@@ -0,0 +1,114 @@
+# Copyright 2026 Firefly Software Foundation.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""X.509 client-certificate authentication (Spring ``x509()``).
+
+Authenticates a request by the client certificate forwarded by the TLS-terminating
+proxy in a header (e.g. ``X-Client-Cert``, PEM, possibly URL-encoded). The
+certificate subject's Common Name becomes the principal; when a
+:class:`~pyfly.security.user_details.UserDetailsService` is configured, the
+principal must resolve to a (enabled) user, whose authorities are applied.
+"""
+
+from __future__ import annotations
+
+import logging
+import re
+from typing import cast
+from urllib.parse import unquote
+
+from starlette.requests import Request
+from starlette.responses import JSONResponse, Response
+
+from pyfly.container.ordering import HIGHEST_PRECEDENCE, order
+from pyfly.context.request_context import RequestContext
+from pyfly.security.context import SecurityContext
+from pyfly.security.user_details import UserDetailsService
+from pyfly.web.filters import OncePerRequestFilter
+from pyfly.web.ports.filter import CallNext
+
+logger = logging.getLogger(__name__)
+
+ERROR_MODE_ANONYMOUS = "anonymous"
+ERROR_MODE_401 = "401"
+
+
+@order(HIGHEST_PRECEDENCE + 218)
+class X509AuthenticationFilter(OncePerRequestFilter):
+ """Authenticates the forwarded client certificate's subject."""
+
+ def __init__(
+ self,
+ *,
+ cert_header: str = "x-client-cert",
+ user_details_service: UserDetailsService | None = None,
+ subject_regex: str | None = None,
+ error_mode: str = ERROR_MODE_ANONYMOUS,
+ ) -> None:
+ self._cert_header = cert_header
+ self._users = user_details_service
+ self._subject_regex = re.compile(subject_regex) if subject_regex else None
+ self._error_mode = error_mode if error_mode in (ERROR_MODE_ANONYMOUS, ERROR_MODE_401) else ERROR_MODE_ANONYMOUS
+
+ async def do_filter(self, request: Request, call_next: CallNext) -> Response:
+ raw = request.headers.get(self._cert_header)
+ if not raw:
+ if not hasattr(request.state, "security_context"):
+ request.state.security_context = SecurityContext.anonymous()
+ return cast(Response, await call_next(request))
+
+ principal = self._extract_principal(unquote(raw))
+ context = await self._build_context(principal) if principal else None
+
+ if context is None:
+ logger.warning("X.509 authentication failed (header=%s)", self._cert_header)
+ if self._error_mode == ERROR_MODE_401:
+ return self._unauthorized()
+ context = SecurityContext.anonymous()
+
+ request.state.security_context = context
+ req_ctx = RequestContext.current()
+ if req_ctx is not None:
+ req_ctx.security_context = context
+ return cast(Response, await call_next(request))
+
+ def _extract_principal(self, pem: str) -> str | None:
+ try:
+ from cryptography import x509
+ from cryptography.x509.oid import NameOID
+
+ cert = x509.load_pem_x509_certificate(pem.encode("utf-8"))
+ except Exception: # malformed / non-PEM certificate
+ return None
+ if self._subject_regex is not None:
+ match = self._subject_regex.search(cert.subject.rfc4514_string())
+ return match.group(1) if match and match.groups() else None
+ common_names = cert.subject.get_attributes_for_oid(NameOID.COMMON_NAME)
+ return str(common_names[0].value) if common_names else None
+
+ async def _build_context(self, principal: str) -> SecurityContext | None:
+ if self._users is None:
+ # Certificate presence is the credential; no authority lookup.
+ return SecurityContext(user_id=principal)
+ user = await self._users.load_user_by_username(principal)
+ if user is None or not user.enabled:
+ return None
+ return SecurityContext(user_id=user.username, roles=list(user.roles), permissions=list(user.permissions))
+
+ @staticmethod
+ def _unauthorized() -> Response:
+ return JSONResponse(
+ {"error": "invalid_client_certificate"},
+ status_code=401,
+ headers={"WWW-Authenticate": "X509"},
+ )
diff --git a/src/pyfly/web/security_filters_auto_configuration.py b/src/pyfly/web/security_filters_auto_configuration.py
index 04b7bf0b..2530dd77 100644
--- a/src/pyfly/web/security_filters_auto_configuration.py
+++ b/src/pyfly/web/security_filters_auto_configuration.py
@@ -50,15 +50,28 @@ def _exclude_patterns(config: Config, key: str) -> Sequence[str]:
@auto_configuration
@conditional_on_class("starlette")
-@conditional_on_property("pyfly.security.csrf.enabled", having_value="true")
+@conditional_on_property("pyfly.security.csrf.enabled", having_value="true", match_if_missing=True)
class CsrfFilterAutoConfiguration:
- """Registers the double-submit-cookie CSRF filter (opt-in)."""
+ """Registers the double-submit-cookie CSRF filter.
+
+ Secure by default: active unless ``pyfly.security.csrf.enabled=false``. The
+ filter runs in cookie-gated mode (``pyfly.security.csrf.cookie-gated``,
+ default true), so stateless/token (no-cookie) clients are unaffected while
+ browser/session requests are protected. Set ``cookie-gated: false`` for
+ strict enforcement of every unsafe request.
+ """
@bean
def csrf_filter(self, config: Config) -> WebFilter:
from pyfly.web.adapters.starlette.filters.csrf_filter import CsrfFilter
- filter_ = CsrfFilter()
+ cookie_gated = str(config.get("pyfly.security.csrf.cookie-gated", True)).strip().lower() not in (
+ "0",
+ "false",
+ "no",
+ "off",
+ )
+ filter_ = CsrfFilter(cookie_gated=cookie_gated)
excludes = _exclude_patterns(config, "pyfly.security.csrf.exclude-patterns")
if excludes:
filter_.exclude_patterns = list(excludes)
diff --git a/tests/config/test_auto.py b/tests/config/test_auto.py
index fc70bca6..65d1e8a4 100644
--- a/tests/config/test_auto.py
+++ b/tests/config/test_auto.py
@@ -46,7 +46,7 @@ def test_detect_messaging_provider(self):
class TestDiscoverAutoConfigurations:
def test_returns_all_auto_config_classes(self):
classes = discover_auto_configurations()
- assert len(classes) == 46
+ assert len(classes) == 49
def test_all_classes_have_auto_configuration_marker(self):
for cls in discover_auto_configurations():
@@ -81,6 +81,9 @@ def test_contains_expected_class_names(self):
"ConfigServerAutoConfiguration",
"CsrfFilterAutoConfiguration",
"HttpSecurityFilterAutoConfiguration",
+ "HttpBasicAutoConfiguration",
+ "FormLoginAutoConfiguration",
+ "LogoutAutoConfiguration",
"CqrsAutoConfiguration",
"DocumentAutoConfiguration",
"EcmAutoConfiguration",
diff --git a/tests/idp/test_azure_ad_behavior.py b/tests/idp/test_azure_ad_behavior.py
index f8121257..b9faced4 100644
--- a/tests/idp/test_azure_ad_behavior.py
+++ b/tests/idp/test_azure_ad_behavior.py
@@ -114,9 +114,21 @@ def _adapter() -> AzureAdIdpAdapter:
tenant_id=TENANT_ID,
client_id=CLIENT_ID,
client_secret=CLIENT_SECRET,
+ allow_password_grant=True,
)
+@pytest.mark.asyncio
+async def test_login_refused_without_password_grant_optin() -> None:
+ """ROPC (grant_type=password) is refused unless explicitly enabled (RFC 9700 §2.4)."""
+ from pyfly.kernel.exceptions import SecurityException
+
+ adapter = AzureAdIdpAdapter(tenant_id=TENANT_ID, client_id=CLIENT_ID, client_secret=CLIENT_SECRET)
+ with pytest.raises(SecurityException) as exc:
+ await adapter.login(LoginRequest(username="alice@example.com", password="s3cr3t!"))
+ assert exc.value.code == "ROPC_DISABLED"
+
+
def _inject(adapter: AzureAdIdpAdapter, fake: FakeClient) -> None:
"""Make every ``await self._client()`` return the same recording fake."""
diff --git a/tests/idp/test_cognito_behavior.py b/tests/idp/test_cognito_behavior.py
index 52aeb426..3ff42570 100644
--- a/tests/idp/test_cognito_behavior.py
+++ b/tests/idp/test_cognito_behavior.py
@@ -117,9 +117,21 @@ def _adapter(fake: _FakeCognitoClient) -> AwsCognitoIdpAdapter:
client_id=CLIENT_ID,
region=REGION,
client=fake,
+ allow_password_grant=True,
)
+@pytest.mark.asyncio
+async def test_login_refused_without_password_grant_optin() -> None:
+ """ROPC (USER_PASSWORD_AUTH) is refused unless explicitly enabled (RFC 9700 §2.4)."""
+ from pyfly.kernel.exceptions import SecurityException
+
+ adapter = AwsCognitoIdpAdapter(user_pool_id=USER_POOL_ID, client_id=CLIENT_ID, region=REGION, client=object())
+ with pytest.raises(SecurityException) as exc:
+ await adapter.login(LoginRequest(username="alice", password="hunter2"))
+ assert exc.value.code == "ROPC_DISABLED"
+
+
# --------------------------------------------------------------------------- #
# login — initiate_auth USER_PASSWORD_AUTH → AuthResult
# --------------------------------------------------------------------------- #
diff --git a/tests/idp/test_keycloak_behavior.py b/tests/idp/test_keycloak_behavior.py
index f72743c4..91ca44bf 100644
--- a/tests/idp/test_keycloak_behavior.py
+++ b/tests/idp/test_keycloak_behavior.py
@@ -103,9 +103,21 @@ def _adapter() -> KeycloakIdpAdapter:
realm=REALM,
client_id="admin-cli",
client_secret="s3cr3t",
+ allow_password_grant=True,
)
+@pytest.mark.asyncio
+async def test_login_refused_without_password_grant_optin() -> None:
+ """ROPC (grant_type=password) is refused unless explicitly enabled (RFC 9700 §2.4)."""
+ from pyfly.kernel.exceptions import SecurityException
+
+ adapter = KeycloakIdpAdapter(base_url=BASE_URL, realm=REALM, client_id="admin-cli", client_secret="s3cr3t")
+ with pytest.raises(SecurityException) as exc:
+ await adapter.login(LoginRequest(username="bob", password="hunter2"))
+ assert exc.value.code == "ROPC_DISABLED"
+
+
def _inject(adapter: KeycloakIdpAdapter, fake: FakeClient) -> None:
"""Make every ``await self._client()`` return the same recording fake."""
diff --git a/tests/idp/test_wave_idp_web.py b/tests/idp/test_wave_idp_web.py
index 148ca276..a2142e3a 100644
--- a/tests/idp/test_wave_idp_web.py
+++ b/tests/idp/test_wave_idp_web.py
@@ -97,7 +97,12 @@ async def test_cognito_login_includes_secret_hash() -> None:
fake = _FakeBoto()
adapter = AwsCognitoIdpAdapter(
- user_pool_id="pool", client_id="cid", region="us-east-1", client_secret="shh", client=fake
+ user_pool_id="pool",
+ client_id="cid",
+ region="us-east-1",
+ client_secret="shh",
+ client=fake,
+ allow_password_grant=True,
)
await adapter.login(LoginRequest(username="bob", password="pw"))
assert "SECRET_HASH" in fake.auth_params # audit #23
diff --git a/tests/security/test_as_asymmetric.py b/tests/security/test_as_asymmetric.py
new file mode 100644
index 00000000..868d0fb1
--- /dev/null
+++ b/tests/security/test_as_asymmetric.py
@@ -0,0 +1,94 @@
+# Copyright 2026 Firefly Software Foundation.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Asymmetric (RS256) authorization-server signing + JWKS publication."""
+
+from __future__ import annotations
+
+import jwt as pyjwt
+import pytest
+from cryptography.hazmat.primitives import serialization
+from cryptography.hazmat.primitives.asymmetric import rsa
+
+from pyfly.security.oauth2.authorization_server import AuthorizationServer, InMemoryTokenStore
+from pyfly.security.oauth2.client import ClientRegistration, InMemoryClientRegistrationRepository
+
+
+def _rsa_pem() -> str:
+ key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
+ return key.private_bytes(
+ serialization.Encoding.PEM,
+ serialization.PrivateFormat.PKCS8,
+ serialization.NoEncryption(),
+ ).decode("utf-8")
+
+
+def _repo() -> InMemoryClientRegistrationRepository:
+ return InMemoryClientRegistrationRepository(
+ ClientRegistration(
+ registration_id="c",
+ client_id="c",
+ client_secret="s3cr3t-value",
+ authorization_grant_type="client_credentials",
+ scopes=["read"],
+ )
+ )
+
+
+def _as_rs256() -> AuthorizationServer:
+ return AuthorizationServer(
+ secret="",
+ client_repository=_repo(),
+ token_store=InMemoryTokenStore(),
+ algorithm="RS256",
+ private_key=_rsa_pem(),
+ key_id="k1",
+ issuer="https://as.example.com",
+ )
+
+
+class TestAsymmetricSigning:
+ @pytest.mark.asyncio
+ async def test_token_verifies_against_published_jwks(self) -> None:
+ server = _as_rs256()
+ result = await server.token(grant_type="client_credentials", client_id="c", client_secret="s3cr3t-value")
+
+ jwks = server.jwks()
+ assert len(jwks["keys"]) == 1
+ key = pyjwt.PyJWK.from_dict(jwks["keys"][0]).key
+ payload = pyjwt.decode(result["access_token"], key, algorithms=["RS256"], issuer="https://as.example.com")
+ assert payload["sub"] == "c"
+ assert payload["scope"] == "read"
+
+ @pytest.mark.asyncio
+ async def test_token_header_carries_kid(self) -> None:
+ server = _as_rs256()
+ result = await server.token(grant_type="client_credentials", client_id="c", client_secret="s3cr3t-value")
+ header = pyjwt.get_unverified_header(result["access_token"])
+ assert header["kid"] == "k1"
+ assert header["alg"] == "RS256"
+
+ def test_jwks_entry_has_kid_use_alg(self) -> None:
+ jwk = _as_rs256().jwks()["keys"][0]
+ assert jwk["kid"] == "k1"
+ assert jwk["use"] == "sig"
+ assert jwk["alg"] == "RS256"
+ assert jwk["kty"] == "RSA"
+
+ def test_hs256_jwks_is_empty(self) -> None:
+ server = AuthorizationServer(
+ secret="symmetric-secret-key-at-least-32b!!",
+ client_repository=_repo(),
+ token_store=InMemoryTokenStore(),
+ )
+ assert server.jwks() == {"keys": []}
diff --git a/tests/security/test_authentication.py b/tests/security/test_authentication.py
new file mode 100644
index 00000000..84bf4b0e
--- /dev/null
+++ b/tests/security/test_authentication.py
@@ -0,0 +1,92 @@
+# Copyright 2026 Firefly Software Foundation.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""AuthenticationManager / AuthenticationProvider SPI."""
+
+from __future__ import annotations
+
+import pytest
+
+from pyfly.security.authentication import (
+ Authentication,
+ AuthenticationException,
+ BadCredentialsException,
+ DaoAuthenticationProvider,
+ DisabledException,
+ ProviderManager,
+)
+from pyfly.security.password import BcryptPasswordEncoder
+from pyfly.security.user_details import InMemoryUserDetailsService, UserDetails
+
+_ENCODER = BcryptPasswordEncoder(rounds=4)
+
+
+def _provider() -> DaoAuthenticationProvider:
+ service = InMemoryUserDetailsService(
+ UserDetails(username="alice", password_hash=_ENCODER.hash("pw"), roles=["ADMIN"], permissions=["read"]),
+ UserDetails(username="bob", password_hash=_ENCODER.hash("pw"), enabled=False),
+ )
+ return DaoAuthenticationProvider(service, _ENCODER)
+
+
+class TestDaoAuthenticationProvider:
+ @pytest.mark.asyncio
+ async def test_valid_credentials_authenticates(self) -> None:
+ result = await _provider().authenticate(Authentication(principal="alice", credentials="pw"))
+ assert result.authenticated is True
+ assert result.principal == "alice"
+ assert "ADMIN" in result.authorities
+ assert "read" in result.authorities
+ assert result.credentials is None # erased after authentication
+
+ @pytest.mark.asyncio
+ async def test_wrong_password_raises_bad_credentials(self) -> None:
+ with pytest.raises(BadCredentialsException):
+ await _provider().authenticate(Authentication(principal="alice", credentials="WRONG"))
+
+ @pytest.mark.asyncio
+ async def test_unknown_user_raises_bad_credentials(self) -> None:
+ with pytest.raises(BadCredentialsException):
+ await _provider().authenticate(Authentication(principal="ghost", credentials="pw"))
+
+ @pytest.mark.asyncio
+ async def test_disabled_user_raises_disabled(self) -> None:
+ with pytest.raises(DisabledException):
+ await _provider().authenticate(Authentication(principal="bob", credentials="pw"))
+
+ def test_supports_password_authentication(self) -> None:
+ assert _provider().supports(Authentication(principal="x", credentials="y")) is True
+ assert _provider().supports(Authentication(principal="x", credentials=None)) is False
+
+
+class TestProviderManager:
+ @pytest.mark.asyncio
+ async def test_delegates_to_supporting_provider(self) -> None:
+ manager = ProviderManager(_provider())
+ result = await manager.authenticate(Authentication(principal="alice", credentials="pw"))
+ assert result.authenticated is True
+ assert result.credentials is None
+
+ @pytest.mark.asyncio
+ async def test_no_supporting_provider_raises(self) -> None:
+ manager = ProviderManager(_provider())
+ with pytest.raises(AuthenticationException):
+ await manager.authenticate(Authentication(principal="x", credentials=None))
+
+ @pytest.mark.asyncio
+ async def test_to_security_context(self) -> None:
+ manager = ProviderManager(_provider())
+ result = await manager.authenticate(Authentication(principal="alice", credentials="pw"))
+ ctx = result.to_security_context()
+ assert ctx.user_id == "alice"
+ assert ctx.is_authenticated
diff --git a/tests/security/test_authorization_server.py b/tests/security/test_authorization_server.py
index 072b1005..9c20b634 100644
--- a/tests/security/test_authorization_server.py
+++ b/tests/security/test_authorization_server.py
@@ -108,13 +108,13 @@ async def test_client_credentials_decodes_valid_jwt(self, auth_server: Authoriza
assert "exp" in payload
@pytest.mark.asyncio
- async def test_client_credentials_custom_scope(self, auth_server: AuthorizationServer) -> None:
- """Passing a custom scope overrides the registration's default scopes."""
+ async def test_client_credentials_requested_scope_subset(self, auth_server: AuthorizationServer) -> None:
+ """A requested scope that is a subset of the registration's scopes is honoured."""
result = await auth_server.token(
grant_type="client_credentials",
client_id="test-client",
client_secret="test-secret",
- scope="admin superuser",
+ scope="read",
)
payload = pyjwt.decode(
@@ -123,8 +123,98 @@ async def test_client_credentials_custom_scope(self, auth_server: AuthorizationS
algorithms=["HS256"],
)
- assert payload["scope"] == "admin superuser"
- assert result["scope"] == "admin superuser"
+ assert payload["scope"] == "read"
+ assert result["scope"] == "read"
+
+ @pytest.mark.asyncio
+ async def test_client_credentials_rejects_unregistered_scope(self, auth_server: AuthorizationServer) -> None:
+ """Requesting a scope the client is not registered for is rejected (RFC 6749 §5.2).
+
+ Prevents privilege escalation: a client registered for ``read write`` must not
+ be able to mint an ``admin`` token by simply asking for it.
+ """
+ with pytest.raises(SecurityException) as exc_info:
+ await auth_server.token(
+ grant_type="client_credentials",
+ client_id="test-client",
+ client_secret="test-secret",
+ scope="admin superuser",
+ )
+ assert exc_info.value.code == "INVALID_SCOPE"
+
+ @pytest.mark.asyncio
+ async def test_client_credentials_partial_unregistered_scope_rejected(
+ self, auth_server: AuthorizationServer
+ ) -> None:
+ """A request mixing a registered and an unregistered scope is rejected wholesale."""
+ with pytest.raises(SecurityException) as exc_info:
+ await auth_server.token(
+ grant_type="client_credentials",
+ client_id="test-client",
+ client_secret="test-secret",
+ scope="read admin",
+ )
+ assert exc_info.value.code == "INVALID_SCOPE"
+
+
+# ---------------------------------------------------------------------------
+# Audience-restricted tokens
+# ---------------------------------------------------------------------------
+
+
+class TestAudienceClaim:
+ """Tokens carry an ``aud`` claim only when an audience is configured."""
+
+ @pytest.fixture
+ def auth_server_with_aud(
+ self,
+ client_repo: InMemoryClientRegistrationRepository,
+ token_store: InMemoryTokenStore,
+ ) -> AuthorizationServer:
+ return AuthorizationServer(
+ secret="test-signing-secret",
+ client_repository=client_repo,
+ token_store=token_store,
+ issuer="https://auth.example.com",
+ audience="api://lumen",
+ )
+
+ @pytest.mark.asyncio
+ async def test_client_credentials_token_includes_aud(self, auth_server_with_aud: AuthorizationServer) -> None:
+ result = await auth_server_with_aud.token(
+ grant_type="client_credentials",
+ client_id="test-client",
+ client_secret="test-secret",
+ )
+ payload = pyjwt.decode(
+ result["access_token"], "test-signing-secret", algorithms=["HS256"], audience="api://lumen"
+ )
+ assert payload["aud"] == "api://lumen"
+
+ @pytest.mark.asyncio
+ async def test_refreshed_token_includes_aud(self, auth_server_with_aud: AuthorizationServer) -> None:
+ initial = await auth_server_with_aud.token(
+ grant_type="client_credentials", client_id="test-client", client_secret="test-secret"
+ )
+ refreshed = await auth_server_with_aud.token(
+ grant_type="refresh_token",
+ client_id="test-client",
+ client_secret="test-secret",
+ refresh_token=initial["refresh_token"],
+ )
+ payload = pyjwt.decode(
+ refreshed["access_token"], "test-signing-secret", algorithms=["HS256"], audience="api://lumen"
+ )
+ assert payload["aud"] == "api://lumen"
+
+ @pytest.mark.asyncio
+ async def test_no_aud_claim_when_audience_not_configured(self, auth_server: AuthorizationServer) -> None:
+ """Backward-compatible: tokens carry no ``aud`` unless an audience is set."""
+ result = await auth_server.token(
+ grant_type="client_credentials", client_id="test-client", client_secret="test-secret"
+ )
+ payload = pyjwt.decode(result["access_token"], "test-signing-secret", algorithms=["HS256"])
+ assert "aud" not in payload
# ---------------------------------------------------------------------------
@@ -182,10 +272,7 @@ async def test_refresh_token_rotation(
refresh_token=old_refresh,
)
- # Old refresh token should be revoked
- assert await token_store.find(old_refresh) is None
-
- # Attempting to reuse the old refresh token should fail
+ # Attempting to reuse the old (rotated) refresh token must fail.
with pytest.raises(SecurityException) as exc_info:
await auth_server.token(
grant_type="refresh_token",
@@ -196,6 +283,37 @@ async def test_refresh_token_rotation(
assert exc_info.value.code == "INVALID_GRANT"
+class TestRefreshTokenReuseDetection:
+ """OAuth 2.1 / RFC 9700: replaying a rotated refresh token revokes the whole family."""
+
+ @pytest.mark.asyncio
+ async def test_reuse_of_rotated_token_revokes_active_descendant(self, auth_server: AuthorizationServer) -> None:
+ initial = await auth_server.token(
+ grant_type="client_credentials", client_id="test-client", client_secret="test-secret"
+ )
+ rt1 = initial["refresh_token"]
+
+ # Rotate rt1 -> rt2 (rt2 is the live token).
+ second = await auth_server.token(
+ grant_type="refresh_token", client_id="test-client", client_secret="test-secret", refresh_token=rt1
+ )
+ rt2 = second["refresh_token"]
+
+ # Replay the consumed rt1 -> reuse detected.
+ with pytest.raises(SecurityException) as exc_info:
+ await auth_server.token(
+ grant_type="refresh_token", client_id="test-client", client_secret="test-secret", refresh_token=rt1
+ )
+ assert exc_info.value.code == "INVALID_GRANT"
+
+ # The whole family is now revoked: the previously-live rt2 no longer works.
+ with pytest.raises(SecurityException) as exc_info2:
+ await auth_server.token(
+ grant_type="refresh_token", client_id="test-client", client_secret="test-secret", refresh_token=rt2
+ )
+ assert exc_info2.value.code == "INVALID_GRANT"
+
+
# ---------------------------------------------------------------------------
# Error cases
# ---------------------------------------------------------------------------
@@ -231,7 +349,7 @@ async def test_unsupported_grant_type(self, auth_server: AuthorizationServer) ->
"""Unsupported grant type raises SecurityException with UNSUPPORTED_GRANT_TYPE."""
with pytest.raises(SecurityException) as exc_info:
await auth_server.token(
- grant_type="authorization_code",
+ grant_type="password", # ROPC — not supported (removed by OAuth 2.1)
client_id="test-client",
client_secret="test-secret",
)
diff --git a/tests/security/test_csrf.py b/tests/security/test_csrf.py
index b45339fe..7b5337ed 100644
--- a/tests/security/test_csrf.py
+++ b/tests/security/test_csrf.py
@@ -16,6 +16,7 @@
from __future__ import annotations
from types import SimpleNamespace
+from typing import Any
from unittest.mock import AsyncMock
import pytest
@@ -90,9 +91,9 @@ async def test_csrf_filter_safe_method_sets_cookie(self) -> None:
assert "XSRF-TOKEN" in cookie_header
@pytest.mark.asyncio
- async def test_csrf_filter_unsafe_method_missing_cookie(self) -> None:
- """POST without CSRF cookie returns 403."""
- csrf_filter = CsrfFilter()
+ async def test_csrf_filter_strict_mode_missing_cookie(self) -> None:
+ """In strict mode, a POST without the CSRF cookie returns 403."""
+ csrf_filter = CsrfFilter(cookie_gated=False)
request = _make_request(
method="POST",
headers={"X-XSRF-TOKEN": "some-token"},
@@ -104,6 +105,34 @@ async def test_csrf_filter_unsafe_method_missing_cookie(self) -> None:
assert result.status_code == 403
call_next.assert_not_awaited()
+ @pytest.mark.asyncio
+ async def test_csrf_filter_cookie_gated_no_cookies_is_exempt(self) -> None:
+ """Default (cookie-gated) mode: a POST carrying NO cookies has no ambient
+ authority to abuse, so it is exempt from CSRF — keeping stateless API
+ clients working when CSRF is on by default."""
+ csrf_filter = CsrfFilter() # cookie_gated=True by default
+ request = _make_request(method="POST", headers={"X-XSRF-TOKEN": "some-token"})
+ response = Response(content="ok", status_code=200)
+ call_next = AsyncMock(return_value=response)
+
+ result = await csrf_filter.do_filter(request, call_next)
+
+ call_next.assert_awaited_once_with(request)
+ assert result is response
+
+ @pytest.mark.asyncio
+ async def test_csrf_filter_cookie_present_requires_token(self) -> None:
+ """A POST that carries a (session) cookie but no valid CSRF pair is rejected,
+ even in cookie-gated mode — that is the actual CSRF scenario."""
+ csrf_filter = CsrfFilter()
+ request = _make_request(method="POST", cookies={"SESSION": "abc"})
+ call_next = AsyncMock()
+
+ result = await csrf_filter.do_filter(request, call_next)
+
+ assert result.status_code == 403
+ call_next.assert_not_awaited()
+
@pytest.mark.asyncio
async def test_csrf_filter_unsafe_method_missing_header(self) -> None:
"""POST with cookie but no header returns 403."""
@@ -171,3 +200,54 @@ async def test_csrf_filter_bearer_bypass(self) -> None:
call_next.assert_awaited_once_with(request)
assert result is response
+
+
+class TestCsrfDefaultOn:
+ """CSRF is wired by default (secure-by-default) unless explicitly disabled."""
+
+ def _app(self, csrf: dict[str, object] | None = None) -> Any:
+ import contextlib
+ from collections.abc import AsyncIterator
+
+ from pyfly.container.stereotypes import rest_controller
+ from pyfly.context.application_context import ApplicationContext
+ from pyfly.core.config import Config
+ from pyfly.web.adapters.starlette.app import create_app
+ from pyfly.web.mappings import get_mapping, request_mapping
+
+ @rest_controller
+ @request_mapping("/api/ping")
+ class _PingController:
+ @get_mapping("/")
+ async def ping(self) -> dict:
+ return {"ok": True}
+
+ security: dict[str, object] = {}
+ if csrf is not None:
+ security["csrf"] = csrf
+ ctx = ApplicationContext(Config({"pyfly": {"security": security}}))
+ ctx.register_bean(_PingController)
+
+ @contextlib.asynccontextmanager
+ async def _lifespan(_app: Any) -> AsyncIterator[None]:
+ await ctx.start()
+ yield
+ await ctx.stop()
+
+ return create_app(context=ctx, lifespan=_lifespan)
+
+ def test_get_sets_xsrf_cookie_by_default(self) -> None:
+ from starlette.testclient import TestClient
+
+ with TestClient(self._app()) as client:
+ resp = client.get("/api/ping/")
+ assert resp.status_code == 200
+ assert "XSRF-TOKEN" in resp.cookies
+
+ def test_can_be_disabled(self) -> None:
+ from starlette.testclient import TestClient
+
+ with TestClient(self._app(csrf={"enabled": "false"})) as client:
+ resp = client.get("/api/ping/")
+ assert resp.status_code == 200
+ assert "XSRF-TOKEN" not in resp.cookies
diff --git a/tests/security/test_dpop_mtls.py b/tests/security/test_dpop_mtls.py
new file mode 100644
index 00000000..7fc8b4f2
--- /dev/null
+++ b/tests/security/test_dpop_mtls.py
@@ -0,0 +1,271 @@
+# Copyright 2026 Firefly Software Foundation.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Sender-constrained tokens — DPoP (RFC 9449) + mTLS (RFC 8705)."""
+
+from __future__ import annotations
+
+import base64
+import datetime
+import hashlib
+import json
+import time
+
+import jwt as pyjwt
+import pytest
+from cryptography import x509
+from cryptography.hazmat.primitives import hashes, serialization
+from cryptography.hazmat.primitives.asymmetric import ec
+from cryptography.x509.oid import NameOID
+
+from pyfly.kernel.exceptions import SecurityException
+from pyfly.security.oauth2.dpop import (
+ DPoPProofValidator,
+ certificate_thumbprint,
+ confirm_dpop_binding,
+ confirm_mtls_binding,
+ jwk_thumbprint,
+)
+
+
+def _ec_key() -> ec.EllipticCurvePrivateKey:
+ return ec.generate_private_key(ec.SECP256R1())
+
+
+def _public_jwk(key: ec.EllipticCurvePrivateKey) -> dict:
+ return json.loads(pyjwt.algorithms.ECAlgorithm.to_jwk(key.public_key()))
+
+
+def _proof(key: ec.EllipticCurvePrivateKey, *, htm: str, htu: str, iat: int | None = None, jti: str = "id1") -> str:
+ claims = {"htm": htm, "htu": htu, "iat": iat if iat is not None else int(time.time()), "jti": jti}
+ return pyjwt.encode(claims, key, algorithm="ES256", headers={"typ": "dpop+jwt", "jwk": _public_jwk(key)})
+
+
+class TestJwkThumbprint:
+ def test_thumbprint_is_stable_and_base64url(self) -> None:
+ key = _ec_key()
+ jwk = _public_jwk(key)
+ t1 = jwk_thumbprint(jwk)
+ t2 = jwk_thumbprint(dict(reversed(list(jwk.items())))) # member order must not matter
+ assert t1 == t2
+ assert "=" not in t1 and "+" not in t1 and "/" not in t1
+
+
+class TestDPoPProofValidator:
+ def test_valid_proof_returns_jkt(self) -> None:
+ key = _ec_key()
+ proof = _proof(key, htm="GET", htu="https://api.example.com/resource")
+ jkt = DPoPProofValidator().validate(proof, http_method="GET", http_url="https://api.example.com/resource")
+ assert jkt == jwk_thumbprint(_public_jwk(key))
+
+ def test_htu_query_is_ignored(self) -> None:
+ key = _ec_key()
+ proof = _proof(key, htm="GET", htu="https://api.example.com/resource")
+ # The request URL may carry a query string; htu compares origin+path only.
+ jkt = DPoPProofValidator().validate(proof, http_method="GET", http_url="https://api.example.com/resource?a=1")
+ assert jkt
+
+ def test_method_mismatch_rejected(self) -> None:
+ key = _ec_key()
+ proof = _proof(key, htm="GET", htu="https://api.example.com/x")
+ with pytest.raises(SecurityException):
+ DPoPProofValidator().validate(proof, http_method="POST", http_url="https://api.example.com/x")
+
+ def test_url_mismatch_rejected(self) -> None:
+ key = _ec_key()
+ proof = _proof(key, htm="GET", htu="https://api.example.com/x")
+ with pytest.raises(SecurityException):
+ DPoPProofValidator().validate(proof, http_method="GET", http_url="https://api.example.com/y")
+
+ def test_stale_proof_rejected(self) -> None:
+ key = _ec_key()
+ proof = _proof(key, htm="GET", htu="https://api.example.com/x", iat=int(time.time()) - 600)
+ with pytest.raises(SecurityException):
+ DPoPProofValidator(max_age_seconds=60).validate(
+ proof, http_method="GET", http_url="https://api.example.com/x"
+ )
+
+ def test_replay_rejected(self) -> None:
+ key = _ec_key()
+ validator = DPoPProofValidator(replay_cache=set())
+ proof = _proof(key, htm="GET", htu="https://api.example.com/x", jti="unique-1")
+ validator.validate(proof, http_method="GET", http_url="https://api.example.com/x")
+ with pytest.raises(SecurityException):
+ validator.validate(proof, http_method="GET", http_url="https://api.example.com/x")
+
+ def test_symmetric_alg_rejected(self) -> None:
+ # A proof must be signed with an asymmetric key; alg=none/HS* is rejected.
+ forged = pyjwt.encode(
+ {"htm": "GET", "htu": "https://api/x", "iat": int(time.time()), "jti": "j"},
+ "secret",
+ algorithm="HS256",
+ headers={"typ": "dpop+jwt", "jwk": {"kty": "oct"}},
+ )
+ with pytest.raises(SecurityException):
+ DPoPProofValidator().validate(forged, http_method="GET", http_url="https://api/x")
+
+
+class TestDPoPBindingConfirmation:
+ def test_matching_jkt_passes(self) -> None:
+ confirm_dpop_binding({"cnf": {"jkt": "abc"}}, "abc")
+
+ def test_mismatched_jkt_raises(self) -> None:
+ with pytest.raises(SecurityException):
+ confirm_dpop_binding({"cnf": {"jkt": "abc"}}, "different")
+
+ def test_missing_cnf_raises(self) -> None:
+ with pytest.raises(SecurityException):
+ confirm_dpop_binding({"sub": "u"}, "abc")
+
+
+def _self_signed_cert() -> bytes:
+ key = ec.generate_private_key(ec.SECP256R1())
+ subject = issuer = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "client")])
+ cert = (
+ x509.CertificateBuilder()
+ .subject_name(subject)
+ .issuer_name(issuer)
+ .public_key(key.public_key())
+ .serial_number(x509.random_serial_number())
+ .not_valid_before(datetime.datetime(2020, 1, 1))
+ .not_valid_after(datetime.datetime(2040, 1, 1))
+ .sign(key, hashes.SHA256())
+ )
+ return cert.public_bytes(serialization.Encoding.PEM)
+
+
+class TestResourceFilterDPoPEnforcement:
+ """The resource-server filter enforces proof-of-possession for cnf-bound tokens."""
+
+ def _filter_and_request(self, jkt: str, *, dpop_header: str | None):
+ from starlette.requests import Request
+
+ from pyfly.security.context import SecurityContext
+ from pyfly.web.adapters.starlette.filters.oauth2_resource_filter import (
+ ERROR_MODE_401,
+ OAuth2ResourceServerFilter,
+ )
+
+ class _FakeValidator:
+ def validate_and_context(self, token: str) -> tuple[dict, SecurityContext]:
+ return {"sub": "u", "cnf": {"jkt": jkt}}, SecurityContext(user_id="u")
+
+ headers: list[tuple[bytes, bytes]] = [(b"authorization", b"DPoP the-access-token")]
+ if dpop_header is not None:
+ headers.append((b"dpop", dpop_header.encode("latin-1")))
+ scope = {
+ "type": "http",
+ "method": "GET",
+ "path": "/r",
+ "headers": headers,
+ "query_string": b"",
+ "scheme": "https",
+ "server": ("api.example.com", 443),
+ }
+ flt = OAuth2ResourceServerFilter(
+ _FakeValidator(), # type: ignore[arg-type]
+ error_mode=ERROR_MODE_401,
+ enforce_sender_constraints=True,
+ )
+ return flt, Request(scope)
+
+ @pytest.mark.asyncio
+ async def test_valid_dpop_proof_accepted(self) -> None:
+ key = _ec_key()
+ jkt = jwk_thumbprint(_public_jwk(key))
+ # ath must match the access token the filter passes ("the-access-token").
+ from pyfly.security.oauth2.dpop import access_token_hash
+
+ claims = {
+ "htm": "GET",
+ "htu": "https://api.example.com/r",
+ "iat": int(time.time()),
+ "jti": "p1",
+ "ath": access_token_hash("the-access-token"),
+ }
+ proof = pyjwt.encode(claims, key, algorithm="ES256", headers={"typ": "dpop+jwt", "jwk": _public_jwk(key)})
+ flt, request = self._filter_and_request(jkt, dpop_header=proof)
+
+ captured = {}
+
+ async def call_next(r):
+ captured["ctx"] = r.state.security_context
+ from starlette.responses import PlainTextResponse
+
+ return PlainTextResponse("ok")
+
+ resp = await flt.do_filter(request, call_next)
+ assert resp.status_code == 200
+ assert captured["ctx"].user_id == "u"
+
+ @pytest.mark.asyncio
+ async def test_missing_dpop_proof_rejected(self) -> None:
+ key = _ec_key()
+ jkt = jwk_thumbprint(_public_jwk(key))
+ flt, request = self._filter_and_request(jkt, dpop_header=None)
+
+ async def call_next(r):
+ from starlette.responses import PlainTextResponse
+
+ return PlainTextResponse("should not reach")
+
+ resp = await flt.do_filter(request, call_next)
+ assert resp.status_code == 401
+
+ @pytest.mark.asyncio
+ async def test_wrong_key_proof_rejected(self) -> None:
+ bound_key = _ec_key()
+ jkt = jwk_thumbprint(_public_jwk(bound_key))
+ # Attacker presents a proof signed with a DIFFERENT key.
+ attacker = _ec_key()
+ from pyfly.security.oauth2.dpop import access_token_hash
+
+ claims = {
+ "htm": "GET",
+ "htu": "https://api.example.com/r",
+ "iat": int(time.time()),
+ "jti": "p2",
+ "ath": access_token_hash("the-access-token"),
+ }
+ proof = pyjwt.encode(
+ claims, attacker, algorithm="ES256", headers={"typ": "dpop+jwt", "jwk": _public_jwk(attacker)}
+ )
+ flt, request = self._filter_and_request(jkt, dpop_header=proof)
+
+ async def call_next(r):
+ from starlette.responses import PlainTextResponse
+
+ return PlainTextResponse("should not reach")
+
+ resp = await flt.do_filter(request, call_next)
+ assert resp.status_code == 401
+
+
+class TestMtlsBinding:
+ def test_thumbprint_matches_manual_sha256(self) -> None:
+ pem = _self_signed_cert()
+ cert = x509.load_pem_x509_certificate(pem)
+ expected = base64.urlsafe_b64encode(hashlib.sha256(cert.public_bytes(serialization.Encoding.DER)).digest())
+ assert certificate_thumbprint(pem) == expected.rstrip(b"=").decode("ascii")
+
+ def test_confirm_matching_cert(self) -> None:
+ pem = _self_signed_cert()
+ thumb = certificate_thumbprint(pem)
+ confirm_mtls_binding({"cnf": {"x5t#S256": thumb}}, pem)
+
+ def test_confirm_mismatched_cert_raises(self) -> None:
+ pem = _self_signed_cert()
+ other = _self_signed_cert()
+ thumb = certificate_thumbprint(other)
+ with pytest.raises(SecurityException):
+ confirm_mtls_binding({"cnf": {"x5t#S256": thumb}}, pem)
diff --git a/tests/security/test_form_login.py b/tests/security/test_form_login.py
new file mode 100644
index 00000000..909ae0c4
--- /dev/null
+++ b/tests/security/test_form_login.py
@@ -0,0 +1,155 @@
+# Copyright 2026 Firefly Software Foundation.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Form-login filter."""
+
+from __future__ import annotations
+
+from typing import Any
+from urllib.parse import urlencode
+
+import pytest
+from starlette.requests import Request
+from starlette.responses import PlainTextResponse, Response
+
+from pyfly.security.authentication import DaoAuthenticationProvider, ProviderManager
+from pyfly.security.password import BcryptPasswordEncoder
+from pyfly.security.user_details import InMemoryUserDetailsService, UserDetails
+from pyfly.session.session import HttpSession
+from pyfly.web.adapters.starlette.filters.form_login_filter import FormLoginFilter
+
+_ENCODER = BcryptPasswordEncoder(rounds=4)
+_SECURITY_CONTEXT_KEY = "SECURITY_CONTEXT"
+
+
+def _manager() -> ProviderManager:
+ service = InMemoryUserDetailsService(
+ UserDetails(username="alice", password_hash=_ENCODER.hash("pw"), roles=["ADMIN"])
+ )
+ return ProviderManager(DaoAuthenticationProvider(service, _ENCODER))
+
+
+def _post(path: str, data: dict[str, str]) -> Request:
+ body = urlencode(data).encode()
+
+ async def receive() -> dict[str, Any]:
+ return {"type": "http.request", "body": body, "more_body": False}
+
+ scope = {
+ "type": "http",
+ "method": "POST",
+ "path": path,
+ "headers": [(b"content-type", b"application/x-www-form-urlencoded")],
+ "query_string": b"",
+ }
+ request = Request(scope, receive)
+ request.state.session = HttpSession("pre-auth-sid", {})
+ return request
+
+
+async def _call_next(request: Request) -> Response:
+ return PlainTextResponse("downstream")
+
+
+class TestFormLoginFilter:
+ @pytest.mark.asyncio
+ async def test_valid_login_establishes_session_context(self) -> None:
+ flt = FormLoginFilter(_manager())
+ request = _post("/login", {"username": "alice", "password": "pw"})
+ resp = await flt.do_filter(request, _call_next)
+ assert resp.status_code == 302
+ assert resp.headers["location"] == "/"
+ ctx = request.state.session.get_attribute(_SECURITY_CONTEXT_KEY)
+ assert ctx is not None and ctx.user_id == "alice" and ctx.has_role("ADMIN")
+
+ @pytest.mark.asyncio
+ async def test_session_id_is_rotated_on_login(self) -> None:
+ flt = FormLoginFilter(_manager())
+ request = _post("/login", {"username": "alice", "password": "pw"})
+ await flt.do_filter(request, _call_next)
+ assert request.state.session.id != "pre-auth-sid" # fixation defense
+
+ @pytest.mark.asyncio
+ async def test_invalid_login_redirects_to_failure(self) -> None:
+ flt = FormLoginFilter(_manager())
+ request = _post("/login", {"username": "alice", "password": "WRONG"})
+ resp = await flt.do_filter(request, _call_next)
+ assert resp.status_code == 302
+ assert "error" in resp.headers["location"]
+ assert request.state.session.get_attribute(_SECURITY_CONTEXT_KEY) is None
+
+ @pytest.mark.asyncio
+ async def test_non_login_request_passes_through(self) -> None:
+ flt = FormLoginFilter(_manager())
+ request = _post("/other", {"x": "y"})
+ resp = await flt.do_filter(request, _call_next)
+ assert resp.body == b"downstream"
+
+ @pytest.mark.asyncio
+ async def test_json_mode_returns_200_and_401(self) -> None:
+ flt = FormLoginFilter(_manager(), use_redirect=False)
+ ok = await flt.do_filter(_post("/login", {"username": "alice", "password": "pw"}), _call_next)
+ assert ok.status_code == 200
+ bad = await flt.do_filter(_post("/login", {"username": "alice", "password": "no"}), _call_next)
+ assert bad.status_code == 401
+
+
+class TestFormLoginAndLogoutAutoConfigEndToEnd:
+ """Form-login and logout auto-configs wire their filters into the live chain."""
+
+ def _client(self) -> Any:
+ import contextlib
+ from collections.abc import AsyncIterator
+
+ from starlette.testclient import TestClient
+
+ from pyfly.context.application_context import ApplicationContext
+ from pyfly.core.config import Config
+ from pyfly.web.adapters.starlette.app import create_app
+
+ config = Config(
+ {
+ "pyfly": {
+ "security": {
+ "csrf": {"enabled": "false"},
+ "form-login": {
+ "enabled": "true",
+ "use-redirect": "false",
+ "users": {"alice": {"password-hash": _ENCODER.hash("pw"), "roles": "ADMIN"}},
+ },
+ "logout": {"enabled": "true", "use-redirect": "false"},
+ }
+ }
+ }
+ )
+ ctx = ApplicationContext(config)
+
+ @contextlib.asynccontextmanager
+ async def _lifespan(_app: Any) -> AsyncIterator[None]:
+ await ctx.start()
+ yield
+ await ctx.stop()
+
+ return TestClient(create_app(context=ctx, lifespan=_lifespan))
+
+ def test_form_login_endpoint_authenticates(self) -> None:
+ with self._client() as client:
+ ok = client.post("/login", data={"username": "alice", "password": "pw"})
+ assert ok.status_code == 200 and ok.json()["authenticated"] is True
+ bad = client.post("/login", data={"username": "alice", "password": "WRONG"})
+ assert bad.status_code == 401
+
+ def test_logout_endpoint_wired(self) -> None:
+ with self._client() as client:
+ resp = client.post("/logout")
+ assert resp.status_code == 204
diff --git a/tests/security/test_http_basic.py b/tests/security/test_http_basic.py
new file mode 100644
index 00000000..9e0a9982
--- /dev/null
+++ b/tests/security/test_http_basic.py
@@ -0,0 +1,193 @@
+# Copyright 2026 Firefly Software Foundation.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Tests for HTTP Basic authentication (UserDetailsService + filter)."""
+
+from __future__ import annotations
+
+import base64
+from typing import Any
+
+import pytest
+from starlette.requests import Request
+from starlette.responses import PlainTextResponse, Response
+
+from pyfly.security.password import BcryptPasswordEncoder
+from pyfly.security.user_details import InMemoryUserDetailsService, UserDetails, UserDetailsService
+from pyfly.web.adapters.starlette.filters.http_basic_filter import HttpBasicAuthenticationFilter
+
+_ENCODER = BcryptPasswordEncoder(rounds=4)
+
+
+def _service() -> InMemoryUserDetailsService:
+ return InMemoryUserDetailsService(
+ UserDetails(username="alice", password_hash=_ENCODER.hash("s3cret"), roles=["ADMIN"]),
+ UserDetails(username="bob", password_hash=_ENCODER.hash("hunter2"), roles=["USER"], enabled=False),
+ )
+
+
+def _request(auth_header: str | None = None) -> Request:
+ headers: list[tuple[bytes, bytes]] = []
+ if auth_header is not None:
+ headers.append((b"authorization", auth_header.encode("latin-1")))
+ scope: dict[str, Any] = {"type": "http", "method": "GET", "path": "/x", "headers": headers, "query_string": b""}
+ return Request(scope)
+
+
+def _basic(username: str, password: str) -> str:
+ token = base64.b64encode(f"{username}:{password}".encode()).decode("ascii")
+ return f"Basic {token}"
+
+
+async def _call_next(request: Request) -> Response:
+ return PlainTextResponse("ok")
+
+
+class TestInMemoryUserDetailsService:
+ @pytest.mark.asyncio
+ async def test_loads_known_user(self) -> None:
+ svc = _service()
+ user = await svc.load_user_by_username("alice")
+ assert user is not None and user.username == "alice"
+
+ @pytest.mark.asyncio
+ async def test_unknown_user_is_none(self) -> None:
+ assert await _service().load_user_by_username("nobody") is None
+
+ def test_protocol_conformance(self) -> None:
+ assert isinstance(_service(), UserDetailsService)
+
+
+class TestHttpBasicFilter:
+ @pytest.mark.asyncio
+ async def test_valid_credentials_set_authenticated_context(self) -> None:
+ f = HttpBasicAuthenticationFilter(_service(), _ENCODER)
+ request = _request(_basic("alice", "s3cret"))
+ response = await f.do_filter(request, _call_next)
+ assert response.status_code == 200
+ ctx = request.state.security_context
+ assert ctx.is_authenticated
+ assert ctx.user_id == "alice"
+ assert ctx.has_role("ADMIN")
+
+ @pytest.mark.asyncio
+ async def test_wrong_password_401_with_challenge(self) -> None:
+ f = HttpBasicAuthenticationFilter(_service(), _ENCODER, error_mode="401", realm="PyFly")
+ response = await f.do_filter(_request(_basic("alice", "wrong")), _call_next)
+ assert response.status_code == 401
+ assert response.headers["WWW-Authenticate"] == 'Basic realm="PyFly"'
+
+ @pytest.mark.asyncio
+ async def test_unknown_user_401(self) -> None:
+ f = HttpBasicAuthenticationFilter(_service(), _ENCODER, error_mode="401")
+ response = await f.do_filter(_request(_basic("ghost", "x")), _call_next)
+ assert response.status_code == 401
+
+ @pytest.mark.asyncio
+ async def test_disabled_user_rejected(self) -> None:
+ f = HttpBasicAuthenticationFilter(_service(), _ENCODER, error_mode="401")
+ response = await f.do_filter(_request(_basic("bob", "hunter2")), _call_next)
+ assert response.status_code == 401
+
+ @pytest.mark.asyncio
+ async def test_wrong_password_anonymous_mode_falls_through(self) -> None:
+ f = HttpBasicAuthenticationFilter(_service(), _ENCODER, error_mode="anonymous")
+ request = _request(_basic("alice", "wrong"))
+ response = await f.do_filter(request, _call_next)
+ assert response.status_code == 200 # gate decides downstream
+ assert not request.state.security_context.is_authenticated
+
+ @pytest.mark.asyncio
+ async def test_no_header_is_anonymous(self) -> None:
+ f = HttpBasicAuthenticationFilter(_service(), _ENCODER, error_mode="401")
+ request = _request(None)
+ response = await f.do_filter(request, _call_next)
+ assert response.status_code == 200 # missing creds fall through to the gate
+ assert not request.state.security_context.is_authenticated
+
+ @pytest.mark.asyncio
+ async def test_non_basic_scheme_ignored(self) -> None:
+ f = HttpBasicAuthenticationFilter(_service(), _ENCODER, error_mode="401")
+ request = _request("Bearer sometoken")
+ response = await f.do_filter(request, _call_next)
+ assert response.status_code == 200
+ assert not request.state.security_context.is_authenticated
+
+ @pytest.mark.asyncio
+ async def test_malformed_base64_rejected(self) -> None:
+ f = HttpBasicAuthenticationFilter(_service(), _ENCODER, error_mode="401")
+ response = await f.do_filter(_request("Basic !!!not-base64!!!"), _call_next)
+ assert response.status_code == 401
+
+
+class TestHttpBasicAutoConfigEndToEnd:
+ """HTTP Basic wired from config, exercised through the full app stack."""
+
+ def _app(self) -> Any:
+ import contextlib
+ from collections.abc import AsyncIterator
+
+ from pyfly.container.stereotypes import rest_controller
+ from pyfly.context.application_context import ApplicationContext
+ from pyfly.core.config import Config
+ from pyfly.web.adapters.starlette.app import create_app
+ from pyfly.web.mappings import get_mapping, request_mapping
+
+ @rest_controller
+ @request_mapping("/api/secret")
+ class _SecretController:
+ @get_mapping("/")
+ async def secret(self) -> dict:
+ return {"ok": True}
+
+ config = Config(
+ {
+ "pyfly": {
+ "security": {
+ "csrf": {"enabled": "false"},
+ "http-basic": {
+ "enabled": "true",
+ "realm": "PyFly",
+ "error-mode": "401",
+ "users": {"alice": {"password-hash": _ENCODER.hash("s3cret"), "roles": "ADMIN"}},
+ },
+ }
+ }
+ }
+ )
+ ctx = ApplicationContext(config)
+ ctx.register_bean(_SecretController)
+
+ @contextlib.asynccontextmanager
+ async def _lifespan(_app: Any) -> AsyncIterator[None]:
+ await ctx.start()
+ yield
+ await ctx.stop()
+
+ return create_app(context=ctx, lifespan=_lifespan)
+
+ def test_valid_basic_credentials_pass(self) -> None:
+ from starlette.testclient import TestClient
+
+ with TestClient(self._app()) as client:
+ resp = client.get("/api/secret/", headers={"Authorization": _basic("alice", "s3cret")})
+ assert resp.status_code == 200
+ assert resp.json() == {"ok": True}
+
+ def test_bad_credentials_get_401_challenge(self) -> None:
+ from starlette.testclient import TestClient
+
+ with TestClient(self._app()) as client:
+ resp = client.get("/api/secret/", headers={"Authorization": _basic("alice", "WRONG")})
+ assert resp.status_code == 401
+ assert resp.headers["WWW-Authenticate"] == 'Basic realm="PyFly"'
diff --git a/tests/security/test_logout.py b/tests/security/test_logout.py
new file mode 100644
index 00000000..bf16980f
--- /dev/null
+++ b/tests/security/test_logout.py
@@ -0,0 +1,78 @@
+# Copyright 2026 Firefly Software Foundation.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Generic logout filter."""
+
+from __future__ import annotations
+
+import pytest
+from starlette.requests import Request
+from starlette.responses import PlainTextResponse, Response
+
+from pyfly.session.session import HttpSession
+from pyfly.web.adapters.starlette.filters.logout_filter import LogoutFilter
+
+
+def _post(path: str) -> Request:
+ scope = {"type": "http", "method": "POST", "path": path, "headers": [], "query_string": b""}
+ request = Request(scope)
+ session = HttpSession("sid", {})
+ session.set_attribute("SECURITY_CONTEXT", object())
+ request.state.session = session
+ return request
+
+
+async def _call_next(request: Request) -> Response:
+ return PlainTextResponse("downstream")
+
+
+class TestLogoutFilter:
+ @pytest.mark.asyncio
+ async def test_logout_invalidates_session_and_redirects(self) -> None:
+ flt = LogoutFilter()
+ request = _post("/logout")
+ resp = await flt.do_filter(request, _call_next)
+ assert resp.status_code == 302
+ assert resp.headers["location"] == "/login?logout"
+ assert request.state.session.invalidated is True
+
+ @pytest.mark.asyncio
+ async def test_logout_clears_configured_cookies(self) -> None:
+ flt = LogoutFilter(delete_cookies=["SESSION", "XSRF-TOKEN"])
+ resp = await flt.do_filter(_post("/logout"), _call_next)
+ set_cookie = (
+ resp.headers.getlist("set-cookie") if hasattr(resp.headers, "getlist") else [resp.headers["set-cookie"]]
+ )
+ joined = " ".join(set_cookie)
+ assert "SESSION=" in joined and "XSRF-TOKEN=" in joined
+
+ @pytest.mark.asyncio
+ async def test_non_logout_passes_through(self) -> None:
+ flt = LogoutFilter()
+ resp = await flt.do_filter(_post("/other"), _call_next)
+ assert resp.body == b"downstream"
+
+ @pytest.mark.asyncio
+ async def test_json_mode_returns_204(self) -> None:
+ flt = LogoutFilter(use_redirect=False)
+ resp = await flt.do_filter(_post("/logout"), _call_next)
+ assert resp.status_code == 204
+
+ @pytest.mark.asyncio
+ async def test_custom_logout_url(self) -> None:
+ flt = LogoutFilter(logout_url="/sign-out")
+ resp = await flt.do_filter(_post("/sign-out"), _call_next)
+ assert resp.status_code == 302
+ # The default path is no longer special.
+ passed = await flt.do_filter(_post("/logout"), _call_next)
+ assert passed.body == b"downstream"
diff --git a/tests/security/test_method_filter.py b/tests/security/test_method_filter.py
new file mode 100644
index 00000000..d24db6ef
--- /dev/null
+++ b/tests/security/test_method_filter.py
@@ -0,0 +1,98 @@
+# Copyright 2026 Firefly Software Foundation.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""@pre_filter / @post_filter collection filtering."""
+
+from __future__ import annotations
+
+from types import SimpleNamespace
+from typing import Any
+
+import pytest
+
+from pyfly.context.request_context import RequestContext
+from pyfly.security.context import SecurityContext
+from pyfly.security.method_security import post_filter, pre_filter
+
+
+@pytest.fixture(autouse=True)
+def _clear_request_context() -> Any:
+ RequestContext.clear()
+ yield
+ RequestContext.clear()
+
+
+def _ctx(user: str = "alice", roles: list[str] | None = None) -> None:
+ ctx = RequestContext.init()
+ ctx.security_context = SecurityContext(user_id=user, roles=roles or [])
+
+
+def _docs() -> list[SimpleNamespace]:
+ return [SimpleNamespace(owner="alice"), SimpleNamespace(owner="bob"), SimpleNamespace(owner="alice")]
+
+
+@post_filter("filterObject.owner == principal.user_id")
+async def list_docs() -> list[SimpleNamespace]:
+ return _docs()
+
+
+@post_filter("filterObject.owner == principal.user_id")
+def list_docs_sync() -> list[SimpleNamespace]:
+ return _docs()
+
+
+@pre_filter("filterObject.owner == principal.user_id", filter_target="docs")
+async def save_all(docs: list[SimpleNamespace]) -> list[SimpleNamespace]:
+ return docs
+
+
+@pre_filter("filterObject.owner == principal.user_id")
+async def save_first_collection(docs: list[SimpleNamespace]) -> list[SimpleNamespace]:
+ return docs
+
+
+class TestPostFilter:
+ @pytest.mark.asyncio
+ async def test_keeps_only_matching_elements(self) -> None:
+ _ctx("alice")
+ result = await list_docs()
+ assert [d.owner for d in result] == ["alice", "alice"]
+
+ @pytest.mark.asyncio
+ async def test_preserves_collection_type(self) -> None:
+ _ctx("alice")
+ assert isinstance(await list_docs(), list)
+
+ def test_sync_method(self) -> None:
+ _ctx("bob")
+ assert [d.owner for d in list_docs_sync()] == ["bob"]
+
+
+class TestPreFilter:
+ @pytest.mark.asyncio
+ async def test_filters_named_argument(self) -> None:
+ _ctx("alice")
+ result = await save_all(docs=_docs())
+ assert [d.owner for d in result] == ["alice", "alice"]
+
+ @pytest.mark.asyncio
+ async def test_filters_positional_argument(self) -> None:
+ _ctx("bob")
+ result = await save_all(_docs())
+ assert [d.owner for d in result] == ["bob"]
+
+ @pytest.mark.asyncio
+ async def test_autodetects_first_collection(self) -> None:
+ _ctx("alice")
+ result = await save_first_collection(_docs())
+ assert [d.owner for d in result] == ["alice", "alice"]
diff --git a/tests/security/test_oauth2_authcode.py b/tests/security/test_oauth2_authcode.py
new file mode 100644
index 00000000..120cc8b2
--- /dev/null
+++ b/tests/security/test_oauth2_authcode.py
@@ -0,0 +1,220 @@
+# Copyright 2026 Firefly Software Foundation.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""OAuth2 authorization_code grant (PKCE, single-use codes, OIDC id_token)."""
+
+from __future__ import annotations
+
+import base64
+import hashlib
+
+import jwt as pyjwt
+import pytest
+
+from pyfly.kernel.exceptions import SecurityException
+from pyfly.security.oauth2.authorization_server import AuthorizationServer, InMemoryTokenStore
+from pyfly.security.oauth2.client import ClientRegistration, InMemoryClientRegistrationRepository
+
+_SECRET = "authorization-server-secret-32bytes!!"
+
+
+def _s256(verifier: str) -> str:
+ return base64.urlsafe_b64encode(hashlib.sha256(verifier.encode("ascii")).digest()).rstrip(b"=").decode("ascii")
+
+
+def _repo(*, public: bool = False) -> InMemoryClientRegistrationRepository:
+ return InMemoryClientRegistrationRepository(
+ ClientRegistration(
+ registration_id="web",
+ client_id="web",
+ client_secret="" if public else "web-secret",
+ authorization_grant_type="authorization_code",
+ redirect_uri="https://app.example.com/cb",
+ scopes=["openid", "profile", "read"],
+ )
+ )
+
+
+def _server(*, public: bool = False) -> AuthorizationServer:
+ return AuthorizationServer(
+ secret=_SECRET,
+ client_repository=_repo(public=public),
+ token_store=InMemoryTokenStore(),
+ issuer="https://as.example.com",
+ )
+
+
+async def _authorize(
+ server: AuthorizationServer, *, challenge: str, scope: str = "openid read", **over: object
+) -> dict:
+ kwargs: dict = dict(
+ client_id="web",
+ redirect_uri="https://app.example.com/cb",
+ response_type="code",
+ scope=scope,
+ state="xyz",
+ code_challenge=challenge,
+ code_challenge_method="S256",
+ user_id="user-1",
+ nonce="n-123",
+ )
+ kwargs.update(over)
+ return await server.authorize(**kwargs)
+
+
+class TestAuthorize:
+ @pytest.mark.asyncio
+ async def test_issues_code_with_state_and_iss(self) -> None:
+ result = await _authorize(_server(), challenge=_s256("v" * 64))
+ assert result["code"]
+ assert result["state"] == "xyz"
+ assert result["iss"] == "https://as.example.com" # RFC 9207
+
+ @pytest.mark.asyncio
+ async def test_redirect_uri_must_match_exactly(self) -> None:
+ with pytest.raises(SecurityException) as exc:
+ await _authorize(_server(), challenge=_s256("v" * 64), redirect_uri="https://app.example.com/evil")
+ assert exc.value.code == "INVALID_REDIRECT_URI"
+
+ @pytest.mark.asyncio
+ async def test_pkce_is_required(self) -> None:
+ with pytest.raises(SecurityException) as exc:
+ await _authorize(_server(), challenge="")
+ assert exc.value.code == "INVALID_REQUEST"
+
+ @pytest.mark.asyncio
+ async def test_plain_pkce_method_rejected(self) -> None:
+ with pytest.raises(SecurityException) as exc:
+ await _authorize(_server(), challenge=_s256("v" * 64), code_challenge_method="plain")
+ assert exc.value.code == "INVALID_REQUEST"
+
+ @pytest.mark.asyncio
+ async def test_scope_must_be_subset(self) -> None:
+ with pytest.raises(SecurityException) as exc:
+ await _authorize(_server(), challenge=_s256("v" * 64), scope="openid admin")
+ assert exc.value.code == "INVALID_SCOPE"
+
+ @pytest.mark.asyncio
+ async def test_unsupported_response_type(self) -> None:
+ with pytest.raises(SecurityException) as exc:
+ await _authorize(_server(), challenge=_s256("v" * 64), response_type="token")
+ assert exc.value.code == "UNSUPPORTED_RESPONSE_TYPE"
+
+
+class TestCodeExchange:
+ @pytest.mark.asyncio
+ async def test_exchange_mints_tokens_and_id_token(self) -> None:
+ server = _server()
+ verifier = "verifier-" + "v" * 56
+ issued = await _authorize(server, challenge=_s256(verifier), scope="openid read")
+ result = await server.token(
+ grant_type="authorization_code",
+ client_id="web",
+ client_secret="web-secret",
+ code=issued["code"],
+ redirect_uri="https://app.example.com/cb",
+ code_verifier=verifier,
+ )
+ assert "access_token" in result and "refresh_token" in result
+ access = pyjwt.decode(result["access_token"], _SECRET, algorithms=["HS256"], options={"verify_aud": False})
+ assert access["sub"] == "user-1"
+ assert "read" in access["scope"]
+ # OIDC id_token present for the openid scope.
+ idt = pyjwt.decode(result["id_token"], _SECRET, algorithms=["HS256"], audience="web")
+ assert idt["sub"] == "user-1" and idt["aud"] == "web" and idt["nonce"] == "n-123"
+
+ @pytest.mark.asyncio
+ async def test_wrong_verifier_rejected(self) -> None:
+ server = _server()
+ issued = await _authorize(server, challenge=_s256("v" * 64))
+ with pytest.raises(SecurityException) as exc:
+ await server.token(
+ grant_type="authorization_code",
+ client_id="web",
+ client_secret="web-secret",
+ code=issued["code"],
+ redirect_uri="https://app.example.com/cb",
+ code_verifier="wrong-verifier",
+ )
+ assert exc.value.code == "INVALID_GRANT"
+
+ @pytest.mark.asyncio
+ async def test_redirect_uri_mismatch_on_exchange_rejected(self) -> None:
+ server = _server()
+ verifier = "v" * 64
+ issued = await _authorize(server, challenge=_s256(verifier))
+ with pytest.raises(SecurityException) as exc:
+ await server.token(
+ grant_type="authorization_code",
+ client_id="web",
+ client_secret="web-secret",
+ code=issued["code"],
+ redirect_uri="https://app.example.com/other",
+ code_verifier=verifier,
+ )
+ assert exc.value.code == "INVALID_GRANT"
+
+ @pytest.mark.asyncio
+ async def test_code_is_single_use_and_reuse_revokes_tokens(self) -> None:
+ server = _server()
+ verifier = "v" * 64
+ issued = await _authorize(server, challenge=_s256(verifier))
+ first = await server.token(
+ grant_type="authorization_code",
+ client_id="web",
+ client_secret="web-secret",
+ code=issued["code"],
+ redirect_uri="https://app.example.com/cb",
+ code_verifier=verifier,
+ )
+ # Replaying the code fails...
+ with pytest.raises(SecurityException) as exc:
+ await server.token(
+ grant_type="authorization_code",
+ client_id="web",
+ client_secret="web-secret",
+ code=issued["code"],
+ redirect_uri="https://app.example.com/cb",
+ code_verifier=verifier,
+ )
+ assert exc.value.code == "INVALID_GRANT"
+ # ...and the refresh token issued from the first exchange is revoked.
+ with pytest.raises(SecurityException):
+ await server.token(
+ grant_type="refresh_token",
+ client_id="web",
+ client_secret="web-secret",
+ refresh_token=first["refresh_token"],
+ )
+
+ @pytest.mark.asyncio
+ async def test_public_client_uses_pkce_without_secret(self) -> None:
+ server = _server(public=True)
+ verifier = "v" * 64
+ issued = await _authorize(server, challenge=_s256(verifier), scope="read")
+ result = await server.token(
+ grant_type="authorization_code",
+ client_id="web",
+ client_secret="",
+ code=issued["code"],
+ redirect_uri="https://app.example.com/cb",
+ code_verifier=verifier,
+ )
+ assert "access_token" in result
+
+ @pytest.mark.asyncio
+ async def test_public_client_cannot_use_client_credentials(self) -> None:
+ server = _server(public=True)
+ with pytest.raises(SecurityException) as exc:
+ await server.token(grant_type="client_credentials", client_id="web", client_secret="")
+ assert exc.value.code == "INVALID_CLIENT"
diff --git a/tests/security/test_oauth2_authorize_endpoint.py b/tests/security/test_oauth2_authorize_endpoint.py
new file mode 100644
index 00000000..2d6c6c5b
--- /dev/null
+++ b/tests/security/test_oauth2_authorize_endpoint.py
@@ -0,0 +1,131 @@
+# Copyright 2026 Firefly Software Foundation.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""/oauth2/authorize endpoint (resource-owner gate + redirect)."""
+
+from __future__ import annotations
+
+import base64
+import hashlib
+from urllib.parse import parse_qs, urlencode, urlparse
+
+import pytest
+from starlette.requests import Request
+
+from pyfly.security.context import SecurityContext
+from pyfly.security.oauth2.authorization_server import AuthorizationServer, InMemoryTokenStore
+from pyfly.security.oauth2.client import ClientRegistration, InMemoryClientRegistrationRepository
+from pyfly.security.oauth2.endpoints import AuthorizationServerEndpoints
+
+_SECRET = "authorization-server-secret-32bytes!!"
+_CHALLENGE = base64.urlsafe_b64encode(hashlib.sha256(b"v" * 64).digest()).rstrip(b"=").decode("ascii")
+
+
+def _endpoints() -> AuthorizationServerEndpoints:
+ repo = InMemoryClientRegistrationRepository(
+ ClientRegistration(
+ registration_id="web",
+ client_id="web",
+ client_secret="web-secret",
+ authorization_grant_type="authorization_code",
+ redirect_uri="https://app.example.com/cb",
+ scopes=["openid", "read"],
+ )
+ )
+ server = AuthorizationServer(
+ secret=_SECRET, client_repository=repo, token_store=InMemoryTokenStore(), issuer="https://as"
+ )
+ return AuthorizationServerEndpoints(server)
+
+
+def _authorize_request(query: dict[str, str], *, user: str | None) -> Request:
+ scope = {
+ "type": "http",
+ "method": "GET",
+ "path": "/oauth2/authorize",
+ "headers": [],
+ "query_string": urlencode(query).encode(),
+ }
+ request = Request(scope)
+ request.state.security_context = SecurityContext(user_id=user) if user else SecurityContext.anonymous()
+ return request
+
+
+def _base_query(**over: str) -> dict[str, str]:
+ q = {
+ "response_type": "code",
+ "client_id": "web",
+ "redirect_uri": "https://app.example.com/cb",
+ "scope": "openid read",
+ "state": "st-1",
+ "code_challenge": _CHALLENGE,
+ "code_challenge_method": "S256",
+ }
+ q.update(over)
+ return q
+
+
+class TestAuthorizeEndpoint:
+ @pytest.mark.asyncio
+ async def test_authenticated_user_gets_code_redirect(self) -> None:
+ resp = await _endpoints()._authorize(_authorize_request(_base_query(), user="alice"))
+ assert resp.status_code == 302
+ loc = urlparse(resp.headers["location"])
+ assert f"{loc.scheme}://{loc.netloc}{loc.path}" == "https://app.example.com/cb"
+ q = parse_qs(loc.query)
+ assert q["code"] and q["state"] == ["st-1"] and q["iss"] == ["https://as"]
+
+ @pytest.mark.asyncio
+ async def test_anonymous_user_redirected_to_login(self) -> None:
+ resp = await _endpoints()._authorize(_authorize_request(_base_query(), user=None))
+ assert resp.status_code == 302
+ assert resp.headers["location"].startswith("/login?")
+ assert "next=" in resp.headers["location"]
+
+ @pytest.mark.asyncio
+ async def test_bad_redirect_uri_is_not_redirected(self) -> None:
+ req = _authorize_request(_base_query(redirect_uri="https://evil.example.com/cb"), user="alice")
+ resp = await _endpoints()._authorize(req)
+ assert resp.status_code == 400
+ assert b"invalid_redirect_uri" in bytes(resp.body)
+
+ @pytest.mark.asyncio
+ async def test_invalid_scope_redirects_error_to_client(self) -> None:
+ req = _authorize_request(_base_query(scope="openid admin"), user="alice")
+ resp = await _endpoints()._authorize(req)
+ assert resp.status_code == 302
+ q = parse_qs(urlparse(resp.headers["location"]).query)
+ assert q["error"] == ["invalid_scope"] and q["state"] == ["st-1"]
+
+ @pytest.mark.asyncio
+ async def test_missing_pkce_redirects_invalid_request(self) -> None:
+ req = _authorize_request(_base_query(code_challenge=""), user="alice")
+ resp = await _endpoints()._authorize(req)
+ assert resp.status_code == 302
+ q = parse_qs(urlparse(resp.headers["location"]).query)
+ assert q["error"] == ["invalid_request"]
+
+ @pytest.mark.asyncio
+ async def test_end_to_end_code_is_exchangeable(self) -> None:
+ endpoints = _endpoints()
+ resp = await endpoints._authorize(_authorize_request(_base_query(scope="read"), user="alice"))
+ code = parse_qs(urlparse(resp.headers["location"]).query)["code"][0]
+ result = await endpoints._server.token(
+ grant_type="authorization_code",
+ client_id="web",
+ client_secret="web-secret",
+ code=code,
+ redirect_uri="https://app.example.com/cb",
+ code_verifier="v" * 64,
+ )
+ assert "access_token" in result
diff --git a/tests/security/test_oauth2_dcr.py b/tests/security/test_oauth2_dcr.py
new file mode 100644
index 00000000..608b6436
--- /dev/null
+++ b/tests/security/test_oauth2_dcr.py
@@ -0,0 +1,86 @@
+# Copyright 2026 Firefly Software Foundation.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Dynamic Client Registration (RFC 7591)."""
+
+from __future__ import annotations
+
+import pytest
+from starlette.applications import Starlette
+from starlette.testclient import TestClient
+
+from pyfly.kernel.exceptions import SecurityException
+from pyfly.security.oauth2.authorization_server import AuthorizationServer, InMemoryTokenStore
+from pyfly.security.oauth2.client import InMemoryClientRegistrationRepository
+from pyfly.security.oauth2.endpoints import AuthorizationServerEndpoints
+
+_SECRET = "authorization-server-secret-32bytes!!"
+
+
+def _server(**kwargs: object) -> AuthorizationServer:
+ return AuthorizationServer(
+ secret=_SECRET,
+ client_repository=InMemoryClientRegistrationRepository(),
+ token_store=InMemoryTokenStore(),
+ **kwargs, # type: ignore[arg-type]
+ )
+
+
+class TestRegisterClientMethod:
+ @pytest.mark.asyncio
+ async def test_registration_creates_usable_client(self) -> None:
+ server = _server(allow_dynamic_registration=True)
+ result = await server.register_client(
+ {"client_name": "app", "grant_types": ["client_credentials"], "scope": "read"}
+ )
+ assert result["client_id"] and result["client_secret"]
+ assert result["client_secret_expires_at"] == 0
+ # The new client can now authenticate.
+ assert server.authenticate_client(result["client_id"], result["client_secret"]) is not None
+
+ @pytest.mark.asyncio
+ async def test_registration_disabled_raises(self) -> None:
+ with pytest.raises(SecurityException) as exc:
+ await _server(allow_dynamic_registration=False).register_client({"client_name": "x"})
+ assert exc.value.code == "REGISTRATION_DISABLED"
+
+
+class TestRegisterEndpoint:
+ def _client(self, server: AuthorizationServer) -> TestClient:
+ return TestClient(Starlette(routes=AuthorizationServerEndpoints(server).routes()))
+
+ def test_open_registration_when_enabled(self) -> None:
+ client = self._client(_server(allow_dynamic_registration=True))
+ resp = client.post("/oauth2/register", json={"client_name": "app", "scope": "read"})
+ assert resp.status_code == 201
+ assert resp.json()["client_id"]
+
+ def test_protected_registration_requires_initial_token(self) -> None:
+ server = _server(allow_dynamic_registration=True, registration_access_token="secret-iat")
+ client = self._client(server)
+ # No / wrong initial access token -> 401.
+ assert client.post("/oauth2/register", json={"client_name": "x"}).status_code == 401
+ assert (
+ client.post(
+ "/oauth2/register", json={"client_name": "x"}, headers={"Authorization": "Bearer WRONG"}
+ ).status_code
+ == 401
+ )
+ # Correct token -> 201.
+ ok = client.post("/oauth2/register", json={"client_name": "x"}, headers={"Authorization": "Bearer secret-iat"})
+ assert ok.status_code == 201
+
+ def test_registration_disabled_returns_error(self) -> None:
+ client = self._client(_server(allow_dynamic_registration=False))
+ resp = client.post("/oauth2/register", json={"client_name": "x"})
+ assert resp.status_code in (400, 403)
diff --git a/tests/security/test_oauth2_endpoints.py b/tests/security/test_oauth2_endpoints.py
new file mode 100644
index 00000000..6f5bcfd0
--- /dev/null
+++ b/tests/security/test_oauth2_endpoints.py
@@ -0,0 +1,260 @@
+# Copyright 2026 Firefly Software Foundation.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""OAuth2 authorization-server HTTP endpoints (token / introspect / revoke / jwks)."""
+
+from __future__ import annotations
+
+from typing import Any
+
+import pytest
+from starlette.applications import Starlette
+from starlette.testclient import TestClient
+
+from pyfly.security.oauth2.authorization_server import AuthorizationServer, InMemoryTokenStore
+from pyfly.security.oauth2.client import ClientRegistration, InMemoryClientRegistrationRepository
+from pyfly.security.oauth2.endpoints import AuthorizationServerEndpoints
+
+_SECRET = "authorization-server-secret-32bytes!!"
+
+
+def _server() -> AuthorizationServer:
+ repo = InMemoryClientRegistrationRepository(
+ ClientRegistration(
+ registration_id="svc",
+ client_id="svc",
+ client_secret="svc-secret",
+ authorization_grant_type="client_credentials",
+ scopes=["read", "write"],
+ )
+ )
+ return AuthorizationServer(
+ secret=_SECRET, client_repository=repo, token_store=InMemoryTokenStore(), issuer="https://as"
+ )
+
+
+def _client(server: AuthorizationServer | None = None) -> TestClient:
+ server = server or _server()
+ app = Starlette(routes=AuthorizationServerEndpoints(server).routes())
+ return TestClient(app)
+
+
+class TestIntrospectMethod:
+ @pytest.mark.asyncio
+ async def test_active_access_token(self) -> None:
+ server = _server()
+ tok = await server.token(grant_type="client_credentials", client_id="svc", client_secret="svc-secret")
+ result = await server.introspect(tok["access_token"])
+ assert result["active"] is True
+ assert result["sub"] == "svc"
+ assert result["scope"] == "read write"
+
+ @pytest.mark.asyncio
+ async def test_active_refresh_token(self) -> None:
+ server = _server()
+ tok = await server.token(grant_type="client_credentials", client_id="svc", client_secret="svc-secret")
+ result = await server.introspect(tok["refresh_token"])
+ assert result["active"] is True
+ assert result["token_type"] == "refresh_token"
+
+ @pytest.mark.asyncio
+ async def test_unknown_token_inactive(self) -> None:
+ assert (await _server().introspect("garbage"))["active"] is False
+
+
+class TestEndpoints:
+ def test_token_endpoint_issues_token(self) -> None:
+ resp = _client().post(
+ "/oauth2/token",
+ data={"grant_type": "client_credentials", "client_id": "svc", "client_secret": "svc-secret"},
+ )
+ assert resp.status_code == 200
+ assert "access_token" in resp.json()
+
+ def test_token_endpoint_bad_secret(self) -> None:
+ resp = _client().post(
+ "/oauth2/token",
+ data={"grant_type": "client_credentials", "client_id": "svc", "client_secret": "WRONG"},
+ )
+ assert resp.status_code == 401
+ assert resp.json()["error"] == "invalid_client"
+
+ def test_jwks_endpoint(self) -> None:
+ resp = _client().get("/oauth2/jwks")
+ assert resp.status_code == 200
+ assert resp.json() == {"keys": []} # HS256 server publishes no keys
+
+ def test_introspect_requires_client_auth(self) -> None:
+ resp = _client().post("/oauth2/introspect", data={"token": "x"})
+ assert resp.status_code == 401
+
+ def test_introspect_active_then_revoke(self) -> None:
+ server = _server()
+ client = _client(server)
+ issued = client.post(
+ "/oauth2/token",
+ data={"grant_type": "client_credentials", "client_id": "svc", "client_secret": "svc-secret"},
+ ).json()
+ rt = issued["refresh_token"]
+ auth = {"client_id": "svc", "client_secret": "svc-secret"}
+
+ introspected = client.post("/oauth2/introspect", data={"token": rt, **auth})
+ assert introspected.status_code == 200
+ assert introspected.json()["active"] is True
+
+ revoked = client.post("/oauth2/revoke", data={"token": rt, **auth})
+ assert revoked.status_code == 200
+
+ again = client.post("/oauth2/introspect", data={"token": rt, **auth})
+ assert again.json()["active"] is False
+
+
+def _two_client_server() -> AuthorizationServer:
+ repo = InMemoryClientRegistrationRepository(
+ ClientRegistration(
+ registration_id="a",
+ client_id="a",
+ client_secret="a-secret",
+ authorization_grant_type="client_credentials",
+ scopes=["read"],
+ ),
+ ClientRegistration(
+ registration_id="b",
+ client_id="b",
+ client_secret="b-secret",
+ authorization_grant_type="client_credentials",
+ scopes=["read"],
+ ),
+ ClientRegistration(
+ registration_id="rs",
+ client_id="rs",
+ client_secret="rs-secret",
+ allow_introspection=True,
+ ),
+ )
+ return AuthorizationServer(secret=_SECRET, client_repository=repo, token_store=InMemoryTokenStore())
+
+
+class TestEndpointAuthorization:
+ """RFC 7009/7662: a client may only act on its own tokens; introspection by a
+ non-owner is allowed only for designated resource-server clients."""
+
+ def test_introspect_other_clients_token_is_inactive(self) -> None:
+ server = _two_client_server()
+ client = _client(server)
+ b_token = client.post(
+ "/oauth2/token", data={"grant_type": "client_credentials", "client_id": "b", "client_secret": "b-secret"}
+ ).json()["access_token"]
+ # Client 'a' tries to introspect client 'b''s token.
+ resp = client.post("/oauth2/introspect", data={"token": b_token, "client_id": "a", "client_secret": "a-secret"})
+ assert resp.json()["active"] is False
+
+ def test_resource_server_client_can_introspect_any_token(self) -> None:
+ server = _two_client_server()
+ client = _client(server)
+ b_token = client.post(
+ "/oauth2/token", data={"grant_type": "client_credentials", "client_id": "b", "client_secret": "b-secret"}
+ ).json()["access_token"]
+ resp = client.post(
+ "/oauth2/introspect", data={"token": b_token, "client_id": "rs", "client_secret": "rs-secret"}
+ )
+ assert resp.json()["active"] is True
+
+ def test_revoke_other_clients_token_is_noop(self) -> None:
+ server = _two_client_server()
+ client = _client(server)
+ issued = client.post(
+ "/oauth2/token", data={"grant_type": "client_credentials", "client_id": "b", "client_secret": "b-secret"}
+ ).json()
+ b_refresh = issued["refresh_token"]
+ # Client 'a' attempts to revoke client 'b''s refresh token.
+ client.post("/oauth2/revoke", data={"token": b_refresh, "client_id": "a", "client_secret": "a-secret"})
+ # 'b''s token is still usable -> it was NOT revoked.
+ refreshed = client.post(
+ "/oauth2/token",
+ data={
+ "grant_type": "refresh_token",
+ "client_id": "b",
+ "client_secret": "b-secret",
+ "refresh_token": b_refresh,
+ },
+ )
+ assert refreshed.status_code == 200
+
+ def test_introspect_rejects_empty_credentials(self) -> None:
+ resp = _client(_two_client_server()).post("/oauth2/introspect", data={"token": "x"})
+ assert resp.status_code == 401
+
+ def test_revoke_rejects_empty_credentials(self) -> None:
+ resp = _client(_two_client_server()).post("/oauth2/revoke", data={"token": "x"})
+ assert resp.status_code == 401
+
+
+class TestOpaqueTokenIntrospector:
+ def test_active_token_builds_context(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ from pyfly.security.oauth2.resource_server import OpaqueTokenIntrospector
+
+ introspector = OpaqueTokenIntrospector(
+ "https://as/oauth2/introspect", client_id="rs", client_secret="rs-secret"
+ )
+
+ class _Resp:
+ status_code = 200
+
+ def json(self) -> dict[str, Any]:
+ return {"active": True, "sub": "user-1", "scope": "read write", "roles": ["ADMIN"]}
+
+ class _C:
+ def __enter__(self) -> _C:
+ return self
+
+ def __exit__(self, *a: object) -> None:
+ return None
+
+ def post(self, *a: Any, **k: Any) -> _Resp:
+ return _Resp()
+
+ import httpx
+
+ monkeypatch.setattr(httpx, "Client", lambda *a, **k: _C())
+ ctx = introspector.to_security_context("opaque-token")
+ assert ctx.user_id == "user-1"
+ assert "read" in ctx.permissions
+
+ def test_inactive_token_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ from pyfly.kernel.exceptions import SecurityException
+ from pyfly.security.oauth2.resource_server import OpaqueTokenIntrospector
+
+ introspector = OpaqueTokenIntrospector("https://as/introspect", client_id="rs", client_secret="s")
+
+ class _Resp:
+ status_code = 200
+
+ def json(self) -> dict[str, Any]:
+ return {"active": False}
+
+ class _C:
+ def __enter__(self) -> _C:
+ return self
+
+ def __exit__(self, *a: object) -> None:
+ return None
+
+ def post(self, *a: Any, **k: Any) -> _Resp:
+ return _Resp()
+
+ import httpx
+
+ monkeypatch.setattr(httpx, "Client", lambda *a, **k: _C())
+ with pytest.raises(SecurityException):
+ introspector.introspect("opaque-token")
diff --git a/tests/security/test_oauth2_metadata.py b/tests/security/test_oauth2_metadata.py
new file mode 100644
index 00000000..024ab1c2
--- /dev/null
+++ b/tests/security/test_oauth2_metadata.py
@@ -0,0 +1,57 @@
+# Copyright 2026 Firefly Software Foundation.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Authorization-server metadata (RFC 8414) + OIDC discovery."""
+
+from __future__ import annotations
+
+from starlette.applications import Starlette
+from starlette.testclient import TestClient
+
+from pyfly.security.oauth2.authorization_server import AuthorizationServer, InMemoryTokenStore
+from pyfly.security.oauth2.client import InMemoryClientRegistrationRepository
+from pyfly.security.oauth2.endpoints import AuthorizationServerEndpoints
+
+_SECRET = "authorization-server-secret-32bytes!!"
+
+
+def _client() -> TestClient:
+ server = AuthorizationServer(
+ secret=_SECRET,
+ client_repository=InMemoryClientRegistrationRepository(),
+ token_store=InMemoryTokenStore(),
+ issuer="https://as.example.com",
+ )
+ return TestClient(Starlette(routes=AuthorizationServerEndpoints(server).routes()))
+
+
+class TestAuthorizationServerMetadata:
+ def test_oauth_metadata_document(self) -> None:
+ doc = _client().get("/.well-known/oauth-authorization-server").json()
+ assert doc["issuer"] == "https://as.example.com"
+ assert doc["token_endpoint"].endswith("/oauth2/token")
+ assert doc["authorization_endpoint"].endswith("/oauth2/authorize")
+ assert doc["jwks_uri"].endswith("/oauth2/jwks")
+ assert doc["introspection_endpoint"].endswith("/oauth2/introspect")
+ assert doc["revocation_endpoint"].endswith("/oauth2/revoke")
+ assert doc["registration_endpoint"].endswith("/oauth2/register")
+ assert doc["code_challenge_methods_supported"] == ["S256"]
+ assert "authorization_code" in doc["grant_types_supported"]
+ assert doc["response_types_supported"] == ["code"]
+
+ def test_openid_configuration_document(self) -> None:
+ doc = _client().get("/.well-known/openid-configuration").json()
+ assert doc["issuer"] == "https://as.example.com"
+ assert doc["subject_types_supported"] == ["public"]
+ assert "HS256" in doc["id_token_signing_alg_values_supported"]
+ assert "sub" in doc["claims_supported"]
diff --git a/tests/security/test_oauth2_mixup.py b/tests/security/test_oauth2_mixup.py
new file mode 100644
index 00000000..8be13518
--- /dev/null
+++ b/tests/security/test_oauth2_mixup.py
@@ -0,0 +1,115 @@
+# Copyright 2026 Firefly Software Foundation.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""RFC 9207 ``iss`` authorization-response validation (mix-up defense)."""
+
+from __future__ import annotations
+
+import json
+from typing import Any
+
+import pytest
+from starlette.requests import Request
+
+from pyfly.security.oauth2.client import ClientRegistration, InMemoryClientRegistrationRepository
+from pyfly.security.oauth2.login import _OAUTH2_STATE_KEY, OAuth2LoginHandler
+from pyfly.session.session import HttpSession
+
+
+def _handler(**reg_overrides: Any) -> OAuth2LoginHandler:
+ base: dict[str, Any] = dict(
+ registration_id="acme",
+ client_id="cid",
+ client_secret="secret",
+ redirect_uri="https://app/cb",
+ scopes=["openid"],
+ authorization_uri="https://idp/auth",
+ token_uri="https://idp/token",
+ issuer_uri="https://good.example.com",
+ use_pkce=False,
+ )
+ base.update(reg_overrides)
+ return OAuth2LoginHandler(InMemoryClientRegistrationRepository(ClientRegistration(**base)))
+
+
+def _callback(query: str, *, state: str | None = "st") -> Request:
+ scope: dict[str, Any] = {
+ "type": "http",
+ "method": "GET",
+ "path": "/login/oauth2/code/acme",
+ "headers": [],
+ "query_string": query.encode(),
+ "path_params": {"registration_id": "acme"},
+ }
+ request = Request(scope)
+ session = HttpSession("sid", {})
+ if state is not None:
+ session.set_attribute(_OAUTH2_STATE_KEY, state)
+ request.state.session = session
+ return request
+
+
+def _body(resp: Any) -> dict[str, Any]:
+ return json.loads(bytes(resp.body).decode("utf-8"))
+
+
+@pytest.mark.asyncio
+async def test_callback_aborts_on_iss_mismatch() -> None:
+ """A returned iss that differs from the registration's issuer aborts (mix-up)."""
+ handler = _handler()
+ resp = await handler._handle_callback(_callback("state=st&code=abc&iss=https://evil.example.com"))
+ assert resp.status_code == 400
+ assert _body(resp)["error"] == "invalid_iss"
+
+
+@pytest.mark.asyncio
+async def test_callback_requires_iss_when_configured() -> None:
+ """With require_iss=True, a callback lacking the iss param is rejected."""
+ handler = _handler(require_iss=True)
+ resp = await handler._handle_callback(_callback("state=st&code=abc"))
+ assert resp.status_code == 400
+ assert _body(resp)["error"] == "invalid_iss"
+
+
+@pytest.mark.asyncio
+async def test_callback_iss_match_passes_to_token_exchange(monkeypatch: pytest.MonkeyPatch) -> None:
+ """A matching iss passes validation and proceeds to the token exchange."""
+ handler = _handler(require_iss=True)
+
+ async def _fake_exchange(*_a: Any, **_k: Any) -> dict[str, Any]:
+ return {} # empty -> handler returns 502 token_exchange_failed (proves we got past iss)
+
+ monkeypatch.setattr(handler, "_exchange_code", _fake_exchange)
+ resp = await handler._handle_callback(_callback("state=st&code=abc&iss=https://good.example.com"))
+ assert resp.status_code == 502
+
+
+@pytest.mark.asyncio
+async def test_callback_no_iss_param_allowed_when_not_required() -> None:
+ """Default (require_iss=False): a missing iss param does not block the flow."""
+ handler = _handler()
+
+ async def _fake_exchange(*_a: Any, **_k: Any) -> dict[str, Any]:
+ return {}
+
+ monkeypatch_done = False
+
+ async def _patched(*_a: Any, **_k: Any) -> dict[str, Any]:
+ nonlocal monkeypatch_done
+ monkeypatch_done = True
+ return {}
+
+ handler._exchange_code = _patched # type: ignore[assignment]
+ resp = await handler._handle_callback(_callback("state=st&code=abc"))
+ assert resp.status_code == 502
+ assert monkeypatch_done
diff --git a/tests/security/test_oauth2_par_jar.py b/tests/security/test_oauth2_par_jar.py
new file mode 100644
index 00000000..70103f6e
--- /dev/null
+++ b/tests/security/test_oauth2_par_jar.py
@@ -0,0 +1,138 @@
+# Copyright 2026 Firefly Software Foundation.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Pushed Authorization Requests (RFC 9126) + JWT-Secured Authz Requests (RFC 9101)."""
+
+from __future__ import annotations
+
+import base64
+import hashlib
+from urllib.parse import parse_qs, urlencode, urlparse
+
+import jwt as pyjwt
+import pytest
+from starlette.applications import Starlette
+from starlette.requests import Request
+from starlette.testclient import TestClient
+
+from pyfly.security.context import SecurityContext
+from pyfly.security.oauth2.authorization_server import AuthorizationServer, InMemoryTokenStore
+from pyfly.security.oauth2.client import ClientRegistration, InMemoryClientRegistrationRepository
+from pyfly.security.oauth2.endpoints import AuthorizationServerEndpoints
+
+_SECRET = "authorization-server-secret-32bytes!!"
+_CLIENT_SECRET = "web-secret-at-least-32-bytes-long!!!"
+_CHALLENGE = base64.urlsafe_b64encode(hashlib.sha256(b"v" * 64).digest()).rstrip(b"=").decode("ascii")
+
+
+def _endpoints() -> AuthorizationServerEndpoints:
+ repo = InMemoryClientRegistrationRepository(
+ ClientRegistration(
+ registration_id="web",
+ client_id="web",
+ client_secret=_CLIENT_SECRET,
+ authorization_grant_type="authorization_code",
+ redirect_uri="https://app.example.com/cb",
+ scopes=["openid", "read"],
+ )
+ )
+ server = AuthorizationServer(
+ secret=_SECRET, client_repository=repo, token_store=InMemoryTokenStore(), issuer="https://as"
+ )
+ return AuthorizationServerEndpoints(server)
+
+
+def _authorize_request(query: dict[str, str], *, user: str = "alice") -> Request:
+ scope = {
+ "type": "http",
+ "method": "GET",
+ "path": "/oauth2/authorize",
+ "headers": [],
+ "query_string": urlencode(query).encode(),
+ }
+ request = Request(scope)
+ request.state.security_context = SecurityContext(user_id=user)
+ return request
+
+
+_AUTHZ_PARAMS = {
+ "response_type": "code",
+ "redirect_uri": "https://app.example.com/cb",
+ "scope": "read",
+ "state": "st-1",
+ "code_challenge": _CHALLENGE,
+ "code_challenge_method": "S256",
+}
+
+
+class TestPAR:
+ def test_par_requires_client_auth(self) -> None:
+ endpoints = _endpoints()
+ client = TestClient(Starlette(routes=endpoints.routes()))
+ resp = client.post("/oauth2/par", data={**_AUTHZ_PARAMS, "client_id": "web"})
+ assert resp.status_code == 401 # no client secret
+
+ @pytest.mark.asyncio
+ async def test_par_then_authorize(self) -> None:
+ endpoints = _endpoints()
+ client = TestClient(Starlette(routes=endpoints.routes()))
+ pushed = client.post("/oauth2/par", data={**_AUTHZ_PARAMS, "client_id": "web", "client_secret": _CLIENT_SECRET})
+ assert pushed.status_code == 201
+ request_uri = pushed.json()["request_uri"]
+ assert request_uri.startswith("urn:ietf:params:oauth:request_uri:")
+
+ resp = await endpoints._authorize(_authorize_request({"client_id": "web", "request_uri": request_uri}))
+ assert resp.status_code == 302
+ q = parse_qs(urlparse(resp.headers["location"]).query)
+ assert q["code"] and q["state"] == ["st-1"]
+
+ @pytest.mark.asyncio
+ async def test_request_uri_is_single_use(self) -> None:
+ endpoints = _endpoints()
+ client = TestClient(Starlette(routes=endpoints.routes()))
+ request_uri = client.post(
+ "/oauth2/par", data={**_AUTHZ_PARAMS, "client_id": "web", "client_secret": _CLIENT_SECRET}
+ ).json()["request_uri"]
+ await endpoints._authorize(_authorize_request({"client_id": "web", "request_uri": request_uri}))
+ # Second use is rejected.
+ resp = await endpoints._authorize(_authorize_request({"client_id": "web", "request_uri": request_uri}))
+ assert resp.status_code == 400
+
+ @pytest.mark.asyncio
+ async def test_unknown_request_uri_rejected(self) -> None:
+ endpoints = _endpoints()
+ resp = await endpoints._authorize(
+ _authorize_request({"client_id": "web", "request_uri": "urn:ietf:params:oauth:request_uri:nope"})
+ )
+ assert resp.status_code == 400
+
+
+class TestJAR:
+ @pytest.mark.asyncio
+ async def test_signed_request_object_accepted(self) -> None:
+ endpoints = _endpoints()
+ request_jwt = pyjwt.encode({**_AUTHZ_PARAMS, "client_id": "web"}, _CLIENT_SECRET, algorithm="HS256")
+ resp = await endpoints._authorize(_authorize_request({"client_id": "web", "request": request_jwt}))
+ assert resp.status_code == 302
+ q = parse_qs(urlparse(resp.headers["location"]).query)
+ assert q["code"]
+
+ @pytest.mark.asyncio
+ async def test_tampered_request_object_rejected(self) -> None:
+ endpoints = _endpoints()
+ request_jwt = pyjwt.encode(
+ {**_AUTHZ_PARAMS, "client_id": "web"}, "WRONG-KEY-32-bytes-or-more-here!!", algorithm="HS256"
+ )
+ resp = await endpoints._authorize(_authorize_request({"client_id": "web", "request": request_jwt}))
+ assert resp.status_code == 400
+ assert b"invalid_request_object" in bytes(resp.body)
diff --git a/tests/security/test_oauth2_pkce.py b/tests/security/test_oauth2_pkce.py
index d93b7856..53077918 100644
--- a/tests/security/test_oauth2_pkce.py
+++ b/tests/security/test_oauth2_pkce.py
@@ -42,6 +42,20 @@ def _handler(*, use_pkce: bool) -> OAuth2LoginHandler:
return OAuth2LoginHandler(InMemoryClientRegistrationRepository(reg))
+def _reg(**overrides: Any) -> ClientRegistration:
+ base: dict[str, Any] = dict(
+ registration_id="acme",
+ client_id="cid",
+ client_secret="secret",
+ redirect_uri="https://app/cb",
+ scopes=["openid"],
+ authorization_uri="https://idp/auth",
+ token_uri="https://idp/token",
+ )
+ base.update(overrides)
+ return ClientRegistration(**base)
+
+
def _request(rid: str = "acme") -> Request:
scope: dict[str, Any] = {
"type": "http",
@@ -88,6 +102,81 @@ async def test_authorization_omits_pkce_when_disabled() -> None:
assert request.state.session.get_attribute(_OAUTH2_PKCE_VERIFIER_KEY) is None
+def test_pkce_enabled_by_default() -> None:
+ """RFC 9700 / OAuth 2.1: PKCE is on by default for the authorization_code flow."""
+ reg = ClientRegistration(registration_id="x", client_id="c")
+ assert reg.use_pkce is True
+
+
+@pytest.mark.asyncio
+async def test_authorization_adds_pkce_by_default() -> None:
+ """A registration that does not mention PKCE still gets a code_challenge."""
+ handler = OAuth2LoginHandler(InMemoryClientRegistrationRepository(_reg()))
+ request = _request()
+ response = await handler._handle_authorization(request)
+ query = parse_qs(urlparse(response.headers["location"]).query)
+ assert query["code_challenge_method"] == ["S256"]
+ assert request.state.session.get_attribute(_OAUTH2_PKCE_VERIFIER_KEY)
+
+
+@pytest.mark.asyncio
+async def test_public_client_forces_pkce_even_if_disabled() -> None:
+ """A public client (no client_secret) gets PKCE even if it tries to opt out —
+ it has no other defense against authorization-code injection."""
+ handler = OAuth2LoginHandler(InMemoryClientRegistrationRepository(_reg(client_secret="", use_pkce=False)))
+ request = _request()
+ response = await handler._handle_authorization(request)
+ query = parse_qs(urlparse(response.headers["location"]).query)
+ assert query["code_challenge_method"] == ["S256"]
+ assert request.state.session.get_attribute(_OAUTH2_PKCE_VERIFIER_KEY)
+
+
+def test_client_autoconfig_enables_pkce_by_default() -> None:
+ from pyfly.core.config import Config
+ from pyfly.security.auto_configuration import OAuth2ClientAutoConfiguration
+
+ cfg = Config(
+ {
+ "pyfly": {
+ "security": {
+ "oauth2": {
+ "client": {
+ "enabled": "true",
+ "registrations": {"acme": {"client-id": "c", "token-uri": "https://idp/token"}},
+ }
+ }
+ }
+ }
+ }
+ )
+ repo = OAuth2ClientAutoConfiguration().client_registration_repository(cfg)
+ reg = repo.find_by_registration_id("acme")
+ assert reg is not None and reg.use_pkce is True
+
+
+def test_client_autoconfig_pkce_can_be_disabled() -> None:
+ from pyfly.core.config import Config
+ from pyfly.security.auto_configuration import OAuth2ClientAutoConfiguration
+
+ cfg = Config(
+ {
+ "pyfly": {
+ "security": {
+ "oauth2": {
+ "client": {
+ "enabled": "true",
+ "registrations": {"acme": {"client-id": "c", "client-secret": "s", "use-pkce": "false"}},
+ }
+ }
+ }
+ }
+ }
+ )
+ repo = OAuth2ClientAutoConfiguration().client_registration_repository(cfg)
+ reg = repo.find_by_registration_id("acme")
+ assert reg is not None and reg.use_pkce is False
+
+
@pytest.mark.asyncio
async def test_exchange_code_sends_verifier(monkeypatch: pytest.MonkeyPatch) -> None:
captured: dict[str, Any] = {}
diff --git a/tests/security/test_password.py b/tests/security/test_password.py
index 86c140a1..ae0312cd 100644
--- a/tests/security/test_password.py
+++ b/tests/security/test_password.py
@@ -11,11 +11,18 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
-"""Tests for PasswordEncoder protocol and BcryptPasswordEncoder adapter."""
+"""Tests for PasswordEncoder protocol and encoder adapters."""
from __future__ import annotations
-from pyfly.security.password import BcryptPasswordEncoder, PasswordEncoder
+from pyfly.security.password import (
+ BcryptPasswordEncoder,
+ DelegatingPasswordEncoder,
+ PasswordEncoder,
+ Pbkdf2PasswordEncoder,
+ ScryptPasswordEncoder,
+ create_delegating_password_encoder,
+)
class TestBcryptPasswordEncoder:
@@ -56,3 +63,115 @@ def test_empty_password_hashes(self):
assert hashed.startswith("$2b$")
assert encoder.verify("", hashed) is True
assert encoder.verify("non-empty", hashed) is False
+
+
+class TestPbkdf2PasswordEncoder:
+ def test_round_trip(self):
+ enc = Pbkdf2PasswordEncoder(iterations=10_000)
+ hashed = enc.hash("pw")
+ assert enc.verify("pw", hashed) is True
+ assert enc.verify("nope", hashed) is False
+
+ def test_self_describing_format(self):
+ enc = Pbkdf2PasswordEncoder(iterations=10_000)
+ assert enc.hash("pw").startswith("sha256$10000$")
+
+ def test_salt_is_random(self):
+ enc = Pbkdf2PasswordEncoder(iterations=10_000)
+ assert enc.hash("pw") != enc.hash("pw")
+
+ def test_protocol_conformance(self):
+ assert isinstance(Pbkdf2PasswordEncoder(), PasswordEncoder)
+
+ def test_corrupt_hash_returns_false(self):
+ assert Pbkdf2PasswordEncoder().verify("pw", "not-a-valid-hash") is False
+
+
+class TestScryptPasswordEncoder:
+ def test_round_trip(self):
+ enc = ScryptPasswordEncoder(n=2**14)
+ hashed = enc.hash("pw")
+ assert enc.verify("pw", hashed) is True
+ assert enc.verify("bad", hashed) is False
+
+ def test_protocol_conformance(self):
+ assert isinstance(ScryptPasswordEncoder(), PasswordEncoder)
+
+ def test_corrupt_hash_returns_false(self):
+ assert ScryptPasswordEncoder().verify("pw", "garbage") is False
+
+
+class TestDelegatingPasswordEncoder:
+ def _enc(self) -> DelegatingPasswordEncoder:
+ return DelegatingPasswordEncoder(
+ {"bcrypt": BcryptPasswordEncoder(rounds=4), "pbkdf2": Pbkdf2PasswordEncoder(iterations=10_000)},
+ encoding_id="bcrypt",
+ )
+
+ def test_hash_is_prefixed_with_default_id(self):
+ assert self._enc().hash("pw").startswith("{bcrypt}$2b$")
+
+ def test_verify_round_trip(self):
+ enc = self._enc()
+ assert enc.verify("pw", enc.hash("pw")) is True
+ assert enc.verify("bad", enc.hash("pw")) is False
+
+ def test_verify_dispatches_by_prefix(self):
+ enc = self._enc()
+ inner = Pbkdf2PasswordEncoder(iterations=10_000).hash("pw")
+ assert enc.verify("pw", "{pbkdf2}" + inner) is True
+ assert enc.verify("bad", "{pbkdf2}" + inner) is False
+
+ def test_unknown_prefix_returns_false(self):
+ assert self._enc().verify("pw", "{md5}deadbeef") is False
+
+ def test_missing_prefix_returns_false(self):
+ assert self._enc().verify("pw", "$2b$unprefixed") is False
+
+ def test_upgrade_encoding_true_for_non_default_id(self):
+ enc = self._enc()
+ stored = "{pbkdf2}" + Pbkdf2PasswordEncoder(iterations=10_000).hash("pw")
+ assert enc.upgrade_encoding(stored) is True
+
+ def test_upgrade_encoding_false_for_default_id(self):
+ enc = self._enc()
+ assert enc.upgrade_encoding(enc.hash("pw")) is False
+
+ def test_upgrade_encoding_true_for_unprefixed(self):
+ assert self._enc().upgrade_encoding("$2b$legacy") is True
+
+ def test_unknown_default_encoding_id_rejected(self):
+ import pytest
+
+ with pytest.raises(ValueError, match="encoding_id"):
+ DelegatingPasswordEncoder({"bcrypt": BcryptPasswordEncoder(rounds=4)}, encoding_id="pbkdf2")
+
+ def test_protocol_conformance(self):
+ assert isinstance(self._enc(), PasswordEncoder)
+
+
+class TestPasswordEncoderFactory:
+ def test_create_delegating_default_is_bcrypt(self):
+ enc = create_delegating_password_encoder(bcrypt_rounds=4)
+ hashed = enc.hash("pw")
+ assert hashed.startswith("{bcrypt}")
+ assert enc.verify("pw", hashed) is True
+
+ def test_create_delegating_recognizes_pbkdf2_and_scrypt(self):
+ enc = create_delegating_password_encoder(bcrypt_rounds=4)
+ pbkdf2 = "{pbkdf2}" + Pbkdf2PasswordEncoder(iterations=10_000).hash("pw")
+ scrypt = "{scrypt}" + ScryptPasswordEncoder(n=2**14).hash("pw")
+ assert enc.verify("pw", pbkdf2) is True
+ assert enc.verify("pw", scrypt) is True
+
+
+class TestDelegatingEncoderAutoConfig:
+ def test_opt_in_provides_delegating_encoder(self):
+ from pyfly.core.config import Config
+ from pyfly.security.auto_configuration import PasswordEncoderAutoConfiguration
+
+ cfg = Config({"pyfly": {"security": {"password": {"delegating": {"enabled": "true"}, "bcrypt-rounds": 4}}}})
+ enc = PasswordEncoderAutoConfiguration().delegating_password_encoder(cfg)
+ hashed = enc.hash("pw")
+ assert hashed.startswith("{bcrypt}")
+ assert enc.verify("pw", hashed) is True
diff --git a/tests/security/test_permission_evaluator.py b/tests/security/test_permission_evaluator.py
new file mode 100644
index 00000000..266ce6b8
--- /dev/null
+++ b/tests/security/test_permission_evaluator.py
@@ -0,0 +1,68 @@
+# Copyright 2026 Firefly Software Foundation.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""PermissionEvaluator SPI wired into the method-security expression engine."""
+
+from __future__ import annotations
+
+from collections.abc import Iterator
+from typing import Any
+
+import pytest
+
+from pyfly.security.context import SecurityContext
+from pyfly.security.expression import evaluate_security_expression, set_permission_evaluator
+from pyfly.security.permission import PermissionEvaluator
+
+
+class _OwnerEvaluator:
+ def has_permission(self, context: Any, target: Any, permission: str, *, target_type: str | None = None) -> bool:
+ return target == "owned" and permission == "read"
+
+
+@pytest.fixture(autouse=True)
+def _reset_evaluator() -> Iterator[None]:
+ yield
+ set_permission_evaluator(None)
+
+
+def test_protocol_conformance() -> None:
+ assert isinstance(_OwnerEvaluator(), PermissionEvaluator)
+
+
+def test_evaluator_receives_target_and_permission() -> None:
+ set_permission_evaluator(_OwnerEvaluator())
+ ctx = SecurityContext(user_id="u")
+ assert evaluate_security_expression("hasPermission(#doc, 'read')", ctx, args={"doc": "owned"}) is True
+ assert evaluate_security_expression("hasPermission(#doc, 'read')", ctx, args={"doc": "other"}) is False
+
+
+def test_without_evaluator_falls_back_to_context_permission() -> None:
+ ctx = SecurityContext(user_id="u", permissions=["read"])
+ # No evaluator installed: target is ignored, the permission is checked on the context.
+ assert evaluate_security_expression("hasPermission(#doc, 'read')", ctx, args={"doc": "x"}) is True
+ assert evaluate_security_expression("hasPermission(#doc, 'write')", ctx, args={"doc": "x"}) is False
+
+
+def test_three_arg_form_passes_target_type() -> None:
+ captured: dict[str, Any] = {}
+
+ class _Capture:
+ def has_permission(self, context: Any, target: Any, permission: str, *, target_type: str | None = None) -> bool:
+ captured.update(target=target, permission=permission, target_type=target_type)
+ return True
+
+ set_permission_evaluator(_Capture())
+ ctx = SecurityContext(user_id="u")
+ assert evaluate_security_expression("hasPermission(#id, 'Document', 'read')", ctx, args={"id": "7"}) is True
+ assert captured == {"target": "7", "permission": "read", "target_type": "Document"}
diff --git a/tests/security/test_security_hardening.py b/tests/security/test_security_hardening.py
index 1f7d3398..6983e551 100644
--- a/tests/security/test_security_hardening.py
+++ b/tests/security/test_security_hardening.py
@@ -32,6 +32,7 @@
from starlette.requests import Request
from starlette.responses import PlainTextResponse, Response
+from pyfly.core.config import Config
from pyfly.kernel.exceptions import SecurityException
from pyfly.security.context import SecurityContext
from pyfly.security.http_security import HttpSecurity
@@ -145,3 +146,110 @@ async def test_any_request_permit_all_restores_open_behavior(self) -> None:
async def test_empty_httpsecurity_is_a_noop(self) -> None:
response = await HttpSecurity().build().do_filter(self._request("/anything"), self._call_next)
assert response.status_code == 200
+
+
+class TestHttpMethodMatchers:
+ """URL authorization rules can be scoped to specific HTTP methods (Spring's
+ ``requestMatchers(HttpMethod.X, ...)``), so a read can be public while a write
+ on the same path requires a role."""
+
+ @staticmethod
+ def _request(path: str, method: str = "GET", ctx: SecurityContext | None = None) -> Request:
+ scope: dict[str, Any] = {"type": "http", "method": method, "path": path, "headers": [], "query_string": b""}
+ request = Request(scope)
+ request.state.security_context = ctx or SecurityContext.anonymous()
+ return request
+
+ @staticmethod
+ async def _call_next(request: Request) -> Response:
+ return PlainTextResponse("ok")
+
+ @pytest.mark.asyncio
+ async def test_method_specific_rule_only_matches_that_method(self) -> None:
+ sec = HttpSecurity()
+ builder = sec.authorize_requests()
+ builder.request_matchers("/api/**", methods="POST").authenticated()
+ builder.request_matchers("/api/**").permit_all()
+ built = sec.build()
+ # GET falls past the POST rule to the permit-all rule.
+ assert (await built.do_filter(self._request("/api/x", "GET"), self._call_next)).status_code == 200
+ # POST (anonymous) matches the method-scoped authenticated rule.
+ assert (await built.do_filter(self._request("/api/x", "POST"), self._call_next)).status_code == 401
+
+ @pytest.mark.asyncio
+ async def test_method_list_matches_any_listed(self) -> None:
+ sec = HttpSecurity()
+ sec.authorize_requests().request_matchers("/api/**", methods=["PUT", "DELETE"]).has_role("ADMIN")
+ built = sec.build()
+ # GET matches no rule -> deny-by-default 403.
+ assert (await built.do_filter(self._request("/api/x", "GET"), self._call_next)).status_code == 403
+ # DELETE matches the method-scoped role rule (anonymous -> 401).
+ assert (await built.do_filter(self._request("/api/x", "DELETE"), self._call_next)).status_code == 401
+
+ @pytest.mark.asyncio
+ async def test_no_method_means_any_method(self) -> None:
+ sec = HttpSecurity()
+ sec.authorize_requests().request_matchers("/api/**").permit_all()
+ built = sec.build()
+ for method in ("GET", "POST", "PUT", "PATCH", "DELETE"):
+ resp = await built.do_filter(self._request("/api/x", method), self._call_next)
+ assert resp.status_code == 200
+
+
+class TestSigningSecretHardening:
+ """The auto-config composition root refuses to sign tokens with the built-in
+ placeholder secret or a secret too short for the HMAC algorithm (RFC 7518 §3.2)."""
+
+ def _as_config(self, **overrides: Any) -> Config:
+ server: dict[str, Any] = {"enabled": "true"}
+ server.update(overrides)
+ return Config({"pyfly": {"security": {"oauth2": {"authorization-server": server}}}})
+
+ def _build_as(self, config: Config) -> AuthorizationServer:
+ from pyfly.container.container import Container
+ from pyfly.security.auto_configuration import OAuth2AuthorizationServerAutoConfiguration
+
+ ac = OAuth2AuthorizationServerAutoConfiguration()
+ repo = InMemoryClientRegistrationRepository()
+ return ac.authorization_server(config, repo, Container())
+
+ def test_authorization_server_bean_rejects_placeholder_secret(self) -> None:
+ with pytest.raises(SecurityException) as exc:
+ self._build_as(self._as_config()) # no secret -> placeholder default
+ assert exc.value.code == "INSECURE_SIGNING_SECRET"
+
+ def test_authorization_server_bean_rejects_short_secret(self) -> None:
+ with pytest.raises(SecurityException) as exc:
+ self._build_as(self._as_config(secret="too-short"))
+ assert exc.value.code == "WEAK_SIGNING_SECRET"
+
+ def test_authorization_server_bean_accepts_strong_secret(self) -> None:
+ server = self._build_as(self._as_config(secret="a" * 32))
+ assert isinstance(server, AuthorizationServer)
+
+ def test_jwt_service_bean_rejects_placeholder_secret_when_filter_enabled(self) -> None:
+ from pyfly.core.config import Config
+ from pyfly.security.auto_configuration import JwtAutoConfiguration
+
+ cfg = Config({"pyfly": {"security": {"enabled": "true", "jwt": {"filter": {"enabled": "true"}}}}})
+ with pytest.raises(SecurityException) as exc:
+ JwtAutoConfiguration().jwt_service(cfg)
+ assert exc.value.code == "INSECURE_SIGNING_SECRET"
+
+ def test_jwt_service_without_filter_tolerates_placeholder(self) -> None:
+ """A resource-server-only app (symmetric JWT filter off) must still boot even
+ though the symmetric signer is left at its (unused) placeholder secret."""
+ from pyfly.core.config import Config
+ from pyfly.security.auto_configuration import JwtAutoConfiguration
+
+ cfg = Config({"pyfly": {"security": {"enabled": "true"}}})
+ svc = JwtAutoConfiguration().jwt_service(cfg)
+ assert svc is not None
+
+ def test_jwt_service_bean_accepts_strong_secret(self) -> None:
+ from pyfly.core.config import Config
+ from pyfly.security.auto_configuration import JwtAutoConfiguration
+
+ cfg = Config({"pyfly": {"security": {"jwt": {"filter": {"enabled": "true"}, "secret": "z" * 40}}}})
+ svc = JwtAutoConfiguration().jwt_service(cfg)
+ assert svc is not None
diff --git a/tests/security/test_sql_user_details.py b/tests/security/test_sql_user_details.py
new file mode 100644
index 00000000..5e5c0714
--- /dev/null
+++ b/tests/security/test_sql_user_details.py
@@ -0,0 +1,84 @@
+# Copyright 2026 Firefly Software Foundation.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""SQL-backed UserDetailsService."""
+
+from __future__ import annotations
+
+from typing import Any
+
+import pytest
+
+pytest.importorskip("sqlalchemy")
+
+from sqlalchemy.ext.asyncio import create_async_engine # noqa: E402
+from sqlalchemy.pool import StaticPool # noqa: E402
+
+from pyfly.security.adapters.sql_user_details import SqlUserDetailsService # noqa: E402
+from pyfly.security.user_details import UserDetails, UserDetailsService # noqa: E402
+
+
+@pytest.fixture
+def engine() -> Any:
+ return create_async_engine("sqlite+aiosqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
+
+
+def _svc(engine: Any) -> SqlUserDetailsService:
+ return SqlUserDetailsService(lambda: engine)
+
+
+class TestSqlUserDetailsService:
+ @pytest.mark.asyncio
+ async def test_save_and_load(self, engine: Any) -> None:
+ svc = _svc(engine)
+ await svc.save(UserDetails(username="alice", password_hash="h", roles=["ADMIN"], permissions=["read"]))
+ user = await svc.load_user_by_username("alice")
+ assert user is not None
+ assert user.username == "alice"
+ assert user.password_hash == "h"
+ assert user.roles == ["ADMIN"]
+ assert user.permissions == ["read"]
+ assert user.enabled is True
+
+ @pytest.mark.asyncio
+ async def test_unknown_user_is_none(self, engine: Any) -> None:
+ assert await _svc(engine).load_user_by_username("ghost") is None
+
+ @pytest.mark.asyncio
+ async def test_save_upserts(self, engine: Any) -> None:
+ svc = _svc(engine)
+ await svc.save(UserDetails(username="a", password_hash="h1"))
+ await svc.save(UserDetails(username="a", password_hash="h2", roles=["X"]))
+ user = await svc.load_user_by_username("a")
+ assert user is not None and user.password_hash == "h2" and user.roles == ["X"]
+
+ @pytest.mark.asyncio
+ async def test_disabled_roundtrips(self, engine: Any) -> None:
+ svc = _svc(engine)
+ await svc.save(UserDetails(username="d", password_hash="h", enabled=False))
+ user = await svc.load_user_by_username("d")
+ assert user is not None and user.enabled is False
+
+ @pytest.mark.asyncio
+ async def test_delete(self, engine: Any) -> None:
+ svc = _svc(engine)
+ await svc.save(UserDetails(username="gone", password_hash="h"))
+ await svc.delete("gone")
+ assert await svc.load_user_by_username("gone") is None
+
+ def test_protocol_conformance(self, engine: Any) -> None:
+ assert isinstance(_svc(engine), UserDetailsService)
+
+ def test_invalid_table_name_rejected(self) -> None:
+ with pytest.raises(ValueError, match="table"):
+ SqlUserDetailsService(lambda: object(), table="users; DROP TABLE x")
diff --git a/tests/security/test_switch_user.py b/tests/security/test_switch_user.py
new file mode 100644
index 00000000..b70a6553
--- /dev/null
+++ b/tests/security/test_switch_user.py
@@ -0,0 +1,116 @@
+# Copyright 2026 Firefly Software Foundation.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""switch-user (run-as impersonation) filter."""
+
+from __future__ import annotations
+
+import pytest
+from starlette.requests import Request
+from starlette.responses import PlainTextResponse, Response
+
+from pyfly.security.context import SecurityContext
+from pyfly.security.user_details import InMemoryUserDetailsService, UserDetails
+from pyfly.session.session import HttpSession
+from pyfly.web.adapters.starlette.filters.switch_user_filter import (
+ PREVIOUS_PRINCIPAL_ROLE,
+ SwitchUserFilter,
+)
+
+_SECURITY_CONTEXT_KEY = "SECURITY_CONTEXT"
+
+
+def _uds() -> InMemoryUserDetailsService:
+ return InMemoryUserDetailsService(
+ UserDetails(username="bob", password_hash="x", roles=["USER"]),
+ UserDetails(username="carol", password_hash="x", roles=["USER"], enabled=False),
+ )
+
+
+def _request(path: str, query: str = "", *, current: SecurityContext | None = None) -> Request:
+ scope = {"type": "http", "method": "GET", "path": path, "headers": [], "query_string": query.encode()}
+ request = Request(scope)
+ session = HttpSession("sid", {})
+ if current is not None:
+ session.set_attribute(_SECURITY_CONTEXT_KEY, current)
+ request.state.session = session
+ return request
+
+
+async def _call_next(request: Request) -> Response:
+ return PlainTextResponse("downstream")
+
+
+def _admin() -> SecurityContext:
+ return SecurityContext(user_id="admin", roles=["ADMIN"])
+
+
+class TestSwitchUserFilter:
+ @pytest.mark.asyncio
+ async def test_admin_can_impersonate(self) -> None:
+ flt = SwitchUserFilter(_uds())
+ request = _request("/login/impersonate", "username=bob", current=_admin())
+ resp = await flt.do_filter(request, _call_next)
+ assert resp.status_code == 302
+ ctx = request.state.session.get_attribute(_SECURITY_CONTEXT_KEY)
+ assert ctx.user_id == "bob"
+ assert ctx.has_role(PREVIOUS_PRINCIPAL_ROLE) # marker for "currently impersonating"
+
+ @pytest.mark.asyncio
+ async def test_non_admin_forbidden(self) -> None:
+ flt = SwitchUserFilter(_uds())
+ request = _request("/login/impersonate", "username=bob", current=SecurityContext(user_id="joe", roles=["USER"]))
+ resp = await flt.do_filter(request, _call_next)
+ assert resp.status_code == 403
+
+ @pytest.mark.asyncio
+ async def test_unauthenticated_rejected(self) -> None:
+ flt = SwitchUserFilter(_uds())
+ resp = await flt.do_filter(_request("/login/impersonate", "username=bob"), _call_next)
+ assert resp.status_code == 401
+
+ @pytest.mark.asyncio
+ async def test_unknown_target_not_found(self) -> None:
+ flt = SwitchUserFilter(_uds())
+ resp = await flt.do_filter(_request("/login/impersonate", "username=ghost", current=_admin()), _call_next)
+ assert resp.status_code == 404
+
+ @pytest.mark.asyncio
+ async def test_disabled_target_not_found(self) -> None:
+ flt = SwitchUserFilter(_uds())
+ resp = await flt.do_filter(_request("/login/impersonate", "username=carol", current=_admin()), _call_next)
+ assert resp.status_code == 404
+
+ @pytest.mark.asyncio
+ async def test_exit_restores_original_principal(self) -> None:
+ flt = SwitchUserFilter(_uds())
+ # First impersonate.
+ request = _request("/login/impersonate", "username=bob", current=_admin())
+ await flt.do_filter(request, _call_next)
+ session = request.state.session
+
+ # Then exit on the same session.
+ exit_req = Request(
+ {"type": "http", "method": "GET", "path": "/logout/impersonate", "headers": [], "query_string": b""}
+ )
+ exit_req.state.session = session
+ resp = await flt.do_filter(exit_req, _call_next)
+ assert resp.status_code == 302
+ restored = session.get_attribute(_SECURITY_CONTEXT_KEY)
+ assert restored.user_id == "admin" and not restored.has_role(PREVIOUS_PRINCIPAL_ROLE)
+
+ @pytest.mark.asyncio
+ async def test_non_switch_path_passes_through(self) -> None:
+ flt = SwitchUserFilter(_uds())
+ resp = await flt.do_filter(_request("/other", current=_admin()), _call_next)
+ assert resp.body == b"downstream"
diff --git a/tests/security/test_x509.py b/tests/security/test_x509.py
new file mode 100644
index 00000000..ac496b0b
--- /dev/null
+++ b/tests/security/test_x509.py
@@ -0,0 +1,103 @@
+# Copyright 2026 Firefly Software Foundation.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""X.509 client-certificate authentication filter."""
+
+from __future__ import annotations
+
+import datetime
+
+import pytest
+from cryptography import x509
+from cryptography.hazmat.primitives import hashes, serialization
+from cryptography.hazmat.primitives.asymmetric import ec
+from cryptography.x509.oid import NameOID
+from starlette.requests import Request
+from starlette.responses import PlainTextResponse, Response
+
+from pyfly.security.user_details import InMemoryUserDetailsService, UserDetails
+from pyfly.web.adapters.starlette.filters.x509_filter import X509AuthenticationFilter
+
+
+def _cert(cn: str) -> str:
+ key = ec.generate_private_key(ec.SECP256R1())
+ name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, cn)])
+ cert = (
+ x509.CertificateBuilder()
+ .subject_name(name)
+ .issuer_name(name)
+ .public_key(key.public_key())
+ .serial_number(x509.random_serial_number())
+ .not_valid_before(datetime.datetime(2020, 1, 1))
+ .not_valid_after(datetime.datetime(2040, 1, 1))
+ .sign(key, hashes.SHA256())
+ )
+ return cert.public_bytes(serialization.Encoding.PEM).decode("ascii")
+
+
+def _request(cert_pem: str | None, header: str = "x-client-cert") -> Request:
+ headers: list[tuple[bytes, bytes]] = []
+ if cert_pem is not None:
+ headers.append((header.encode(), cert_pem.encode("latin-1")))
+ scope = {"type": "http", "method": "GET", "path": "/x", "headers": headers, "query_string": b""}
+ return Request(scope)
+
+
+async def _call_next(request: Request) -> Response:
+ return PlainTextResponse("ok")
+
+
+class TestX509Filter:
+ @pytest.mark.asyncio
+ async def test_cert_without_user_service_authenticates_principal(self) -> None:
+ flt = X509AuthenticationFilter()
+ request = _request(_cert("alice"))
+ await flt.do_filter(request, _call_next)
+ ctx = request.state.security_context
+ assert ctx.is_authenticated and ctx.user_id == "alice"
+
+ @pytest.mark.asyncio
+ async def test_cert_with_user_service_loads_authorities(self) -> None:
+ uds = InMemoryUserDetailsService(UserDetails(username="alice", password_hash="x", roles=["ADMIN"]))
+ flt = X509AuthenticationFilter(user_details_service=uds)
+ request = _request(_cert("alice"))
+ await flt.do_filter(request, _call_next)
+ assert request.state.security_context.has_role("ADMIN")
+
+ @pytest.mark.asyncio
+ async def test_unknown_user_401_mode_rejected(self) -> None:
+ uds = InMemoryUserDetailsService()
+ flt = X509AuthenticationFilter(user_details_service=uds, error_mode="401")
+ resp = await flt.do_filter(_request(_cert("ghost")), _call_next)
+ assert resp.status_code == 401
+
+ @pytest.mark.asyncio
+ async def test_no_cert_is_anonymous(self) -> None:
+ flt = X509AuthenticationFilter(error_mode="401")
+ request = _request(None)
+ resp = await flt.do_filter(request, _call_next)
+ assert resp.status_code == 200
+ assert not request.state.security_context.is_authenticated
+
+ @pytest.mark.asyncio
+ async def test_malformed_cert_401_mode_rejected(self) -> None:
+ flt = X509AuthenticationFilter(error_mode="401")
+ resp = await flt.do_filter(_request("not-a-cert"), _call_next)
+ assert resp.status_code == 401
+
+ @pytest.mark.asyncio
+ async def test_disabled_user_rejected(self) -> None:
+ uds = InMemoryUserDetailsService(UserDetails(username="bob", password_hash="x", enabled=False))
+ flt = X509AuthenticationFilter(user_details_service=uds, error_mode="401")
+ resp = await flt.do_filter(_request(_cert("bob")), _call_next)
+ assert resp.status_code == 401