diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fe9c5f2..b5be5867 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,95 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). --- +## v26.06.114 (2026-06-26) + +A comprehensive security release: PyFly now aligns with **RFC 9700 (OAuth 2.0 +Security Best Current Practice)** and **OAuth 2.1**, reaches broad **Spring +Security parity**, and ships a complete OAuth 2.1 / OpenID Connect stack — followed +by a full security documentation refresh. + +### Added + +- **OAuth 2.1 authorization server.** `AuthorizationServer` implements the + `authorization_code` grant (single-use codes, exact redirect-URI matching, + mandatory PKCE `S256`, code-reuse → token revocation) alongside + `client_credentials` and `refresh_token`. It issues OIDC `id_token`s for the + `openid` scope, supports symmetric (`HS*`) and asymmetric (`RS*`/`ES*`/`PS*`) + signing with a published JWK set (`jwks()`), and is fronted by + `AuthorizationServerEndpoints`: `GET /oauth2/authorize`, `POST /oauth2/par`, + `POST /oauth2/token`, `POST /oauth2/introspect`, `POST /oauth2/revoke`, + `POST /oauth2/register`, `GET /oauth2/jwks`, and the + `/.well-known/oauth-authorization-server` + `/.well-known/openid-configuration` + discovery documents. Includes Dynamic Client Registration (RFC 7591), Pushed + Authorization Requests (RFC 9126), JWT-Secured Authorization Requests (RFC 9101), + token introspection (RFC 7662), revocation (RFC 7009), and AS metadata (RFC 8414). +- **Sender-constrained tokens.** DPoP (RFC 9449) and mTLS (RFC 8705): the + authorization server binds tokens via a `cnf` claim and the resource server + enforces proof-of-possession (`DPoPProofValidator`, `confirm_dpop_binding`, + `confirm_mtls_binding`) when + `pyfly.security.oauth2.resource-server.enforce-sender-constraints=true`. +- **Opaque-token introspection.** `OpaqueTokenIntrospector` validates non-JWT + access tokens via a remote RFC 7662 endpoint, mapping claims identically to the + JWKS validator. +- **Authentication mechanisms (Spring parity).** A `UserDetailsService` SPI + (`InMemoryUserDetailsService`, SQLAlchemy-backed `SqlUserDetailsService`), an + `AuthenticationManager` (`ProviderManager` + `DaoAuthenticationProvider`, with + username-enumeration-resistant timing and credential erasure), and config-driven + **form login** (`FormLoginFilter`), **HTTP Basic** (`HttpBasicAuthenticationFilter`), + **X.509** client-certificate auth (`X509AuthenticationFilter`), generic **logout** + (`LogoutFilter`), and **run-as** impersonation (`SwitchUserFilter`). +- **Password encoders.** `Pbkdf2PasswordEncoder`, `ScryptPasswordEncoder`, + `Argon2PasswordEncoder` (Argon2id; `pip install pyfly[argon2]`), and a + `DelegatingPasswordEncoder` (`{id}`-prefixed, with `upgrade_encoding` for + transparent on-login migration) plus `create_delegating_password_encoder()`. +- **Authorization breadth.** HTTP-method-scoped URL rules + (`request_matchers(..., methods=...)`), method-security collection filtering + (`@pre_filter` / `@post_filter` binding `filterObject`), and a `PermissionEvaluator` + SPI backing ACL-style `hasPermission(target, perm)`. +- **RFC 9207** issuer (`iss`) validation in the OAuth2 client callback (mix-up + attack defense; `ClientRegistration.require_iss`). +- **Configuration.** New keys under `pyfly.security.{http-basic,form-login,logout}.*`, + `pyfly.security.password.delegating.enabled`, `pyfly.security.csrf.cookie-gated`, + `pyfly.security.oauth2.authorization-server.audience`, + `pyfly.security.oauth2.resource-server.{enforce-sender-constraints,mtls-cert-header}`, + per-registration `use-pkce` / `require-iss`, and `pyfly.idp.allow-password-grant`. + New optional extra: `pyfly[argon2]`. + +### Changed + +- **PKCE is on by default** for the OAuth2 `authorization_code` login flow and is + always enforced for public clients (`ClientRegistration.use_pkce` now defaults + `True`). +- **CSRF is enabled by default** in cookie-gated mode (stateless / `Bearer` clients + are unaffected); opt out with `pyfly.security.csrf.enabled=false`, or set + `pyfly.security.csrf.cookie-gated=false` for strict enforcement. +- **`client_credentials` scope validation:** a request for a scope the client is + not registered for is now rejected with `invalid_scope` instead of being echoed. + +### Security + +- **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`, + and requires ≥ 32-byte HMAC keys for `HS*` algorithms (RFC 7518 §3.2). +- **ROPC disabled by default.** The IdP Resource Owner Password Credentials grant + (`grant_type=password`) on the Keycloak / Cognito / Entra adapters is refused + unless `pyfly.idp.allow-password-grant=true` (OAuth 2.1 / RFC 9700 §2.4). +- **Refresh-token reuse detection.** Replaying an already-rotated refresh token + revokes the entire token family. +- **Owner-scoped introspection & revocation.** A client may only introspect / + revoke its own tokens (RFC 7009 §2.1); empty client credentials are rejected. + +### Documentation + +- New **OAuth 2.1 & OpenID Connect** guide (`docs/modules/oauth2.md`). The Security + guide gained *Authentication Mechanisms*, *Security Headers*, and *Secure-by-Default + & Hardening* sections; the Spring comparison, IDP, and web-filters docs were + updated; and *PyFly by Example* chapter 14 (English **and** Spanish) gained + sections on form login, modern password hashing, the authorization server, and + sender-constrained tokens. + +--- + ## v26.06.113 (2026-06-17) ### Added diff --git a/README.md b/README.md index f2bbd26d..8b9a71b8 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Firefly Framework Python 3.12+ License: Apache 2.0 - Version: 26.06.113 + Version: 26.06.114 Type Checked: mypy strict Code Style: Ruff Async First @@ -1064,7 +1064,7 @@ PyFly ships with **39 fully-implemented modules** organized into five layers — | Module | Description | Firefly Java Equivalent | |--------|-------------|------------------------| -| **Security** | JWT, password encoding, authorization | Part of `fireflyframework-starter-application` | +| **Security** | Spring-style URL + method authorization (`HttpSecurity`, `@pre_authorize`/`@post_authorize`/`@pre_filter`/`@post_filter`), form / HTTP-Basic / X.509 login, password encoders (bcrypt / PBKDF2 / scrypt / Argon2 behind a delegating encoder), CSRF + security headers, and full OAuth 2.1 / OIDC — resource server, client & login, and authorization server (PKCE, DPoP, mTLS, introspection, DCR, PAR, JAR) | Part of `fireflyframework-starter-application` | | **Messaging** | Kafka, RabbitMQ, in-memory broker | `fireflyframework-eda` | | **EDA** | Event-driven architecture, event bus | `fireflyframework-eda` | | **Cache** | Caching decorators, Redis adapter | `fireflyframework-cache` | @@ -1185,6 +1185,7 @@ The git tag and human-readable display use the leading-zero form (`v26.05.01`); The full release history lives in **[CHANGELOG.md](CHANGELOG.md)** ([Keep a Changelog](https://keepachangelog.com/) format). Recent highlights: +- **`v26.06.114`** (2026-06-26) — **security overhaul**: full **RFC 9700 / OAuth 2.1** alignment and broad **Spring Security parity** — a complete OAuth 2.1 / OIDC **authorization server** (PKCE, OIDC id tokens, JWKS, introspection/revocation, DCR, PAR, JAR), **DPoP / mTLS** sender-constrained tokens, form / HTTP-Basic / X.509 login, an `AuthenticationManager` / `UserDetailsService` SPI, a delegating password encoder (bcrypt / PBKDF2 / scrypt / Argon2), and secure-by-default hardening (PKCE-default, CSRF-on, ROPC opt-in, signing-secret fail-fast). See the [Security](docs/modules/security.md) and [OAuth2](docs/modules/oauth2.md) guides. - **`v26.06.113`** (2026-06-17) — **server-layer observability**: per-server metrics (active connections, in-flight requests, workers, uptime) across Uvicorn / Granian / Hypercorn, correct multi-worker Prometheus aggregation, and a live admin **Observability** dashboard. - **`v26.06.112`** (2026-06-16) — *PyFly by Example* figures rebuilt in one polished, vector visual language (English + Spanish editions). - **`v26.05.01`** (2026-05-07) — **full Java-framework parity**: the Saga + Workflow + TCC transactional engine, nine new modules (event sourcing, callbacks, webhooks, notifications, IDP, ECM, plugins, rule engine, config server), 12 third-party adapters, and the move to CalVer. diff --git a/book/dist/pyfly-by-example-es.epub b/book/dist/pyfly-by-example-es.epub index 633a000e..8cda7e9f 100644 Binary files a/book/dist/pyfly-by-example-es.epub and b/book/dist/pyfly-by-example-es.epub differ diff --git a/book/dist/pyfly-by-example-es.pdf b/book/dist/pyfly-by-example-es.pdf index 9c69f4aa..f92aef17 100644 Binary files a/book/dist/pyfly-by-example-es.pdf and b/book/dist/pyfly-by-example-es.pdf differ diff --git a/book/dist/pyfly-by-example.epub b/book/dist/pyfly-by-example.epub index da03f5c5..90af465a 100644 Binary files a/book/dist/pyfly-by-example.epub and b/book/dist/pyfly-by-example.epub differ diff --git a/book/dist/pyfly-by-example.pdf b/book/dist/pyfly-by-example.pdf index 10d2219f..d77d83b8 100644 Binary files a/book/dist/pyfly-by-example.pdf and b/book/dist/pyfly-by-example.pdf differ diff --git a/book/manuscript-es/14-security.md b/book/manuscript-es/14-security.md index eba986a6..a2bc7e68 100644 --- a/book/manuscript-es/14-security.md +++ b/book/manuscript-es/14-security.md @@ -1300,6 +1300,562 @@ Cuando Starlette está presente, `IdpAutoConfiguration` también registra un bea --- +## Iniciar sesión con un formulario + +Todo lo visto hasta ahora daba por supuesto un cliente de máquina: acuñar un JWT, +reenviarlo en la cabecera `Authorization` y validarlo a la entrada. Eso es +exactamente lo correcto para la API de Lumen. Pero Lumen también tiene un panel de +administración —un navegador— y los navegadores no llevan cabeceras Bearer. Llevan +cookies. Para el panel quieres lo de siempre, lo aburrido y correcto: un formulario +de usuario/contraseña que hace POST a una URL de inicio de sesión, y una cookie de +sesión que viaja con cada petición posterior. + +PyFly te da eso sin escribir un controlador. `FormLoginFilter` intercepta un POST a +la URL de inicio de sesión, autentica las credenciales a través de un +`ProviderManager` y —algo crucial— **rota el id de sesión** antes de vincular el +`SecurityContext` autenticado a la sesión. La rotación es la defensa contra la +fijación de sesión (session-fixation) de la sección de sesiones, aplicada +automáticamente en el momento en que cambian los privilegios: cualquier id de +sesión que un atacante pudiera haber plantado antes del inicio de sesión se descarta +y se reemplaza. El contexto se almacena en la sesión, y `OAuth2SessionSecurityFilter` +lo restaura en cada petición posterior, de modo que el panel permanece con la sesión +iniciada. + +Declaras los usuarios y las URLs en `pyfly.yaml`. Las contraseñas se almacenan +**pre-hasheadas** —nunca en texto plano— y la autoconfiguración las verifica con +`BcryptPasswordEncoder` a través de un `DaoAuthenticationProvider`: + +```yaml +pyfly: + security: + enabled: true + form-login: + enabled: true + login-url: /login + username-param: username + password-param: password + success-url: /dashboard + failure-url: /login?error + use-redirect: true + users: + alice: + # hash bcrypt de "hunter2" — genéralo con BcryptPasswordEncoder.hash + password-hash: "$2b$12$Q9.../iJ8wq6Yk5fJ0bO" + roles: ADMIN + permissions: wallet:read,wallet:create + enabled: true + logout: + enabled: true + logout-url: /logout + success-url: /login?logout + delete-cookies: PYFLY_SESSION + use-redirect: true +``` + +`FormLoginFilter` se ejecuta en `HIGHEST_PRECEDENCE + 230`, justo después del filtro +que restaura la sesión, de modo que un inicio de sesión nuevo siempre prevalece +sobre cualquier contexto anónimo previo. `LogoutFilter` corre justo detrás, en +`+235`: un POST a la URL de cierre de sesión invalida la sesión, restablece el +contexto de la petición a `SecurityContext.anonymous()` y elimina las cookies que +nombres en `delete-cookies`. Ambos filtros hablan dos dialectos: pon +`use-redirect: false` y responderán con JSON (`{"authenticated": true}` / `204`) en +lugar de un `302`, que es lo que quieres si el panel es una aplicación de página +única (SPA) que llama a `fetch`. + +La autenticación en sí es el `DaoAuthenticationProvider` de la SPI de autenticación +de PyFly. Es deliberadamente cuidadoso en una cosa: un nombre de usuario desconocido +aun así incurre en una verificación de contraseña contra un hash ficticio de usar y +tirar, de modo que el endpoint de inicio de sesión no puede usarse como un oráculo +de nombres de usuario: una contraseña incorrecta y un usuario inexistente tardan lo +mismo y lanzan la misma `BadCredentialsException`. Una cuenta deshabilitada +(`enabled: false`) lanza `DisabledException`. + +!!! tip "Pruébalo — inicia sesión con una cookie y luego cierra sesión" + Arranca la aplicación y haz POST del formulario (fíjate en `-d`, no JSON: esto es + un envío de formulario HTML real): + + ```bash + curl -i -X POST http://localhost:8080/login \ + -d "username=alice&password=hunter2" + ``` + + Esperado — `HTTP/1.1 302 Found`, una cabecera `location: /dashboard` y un + `set-cookie: PYFLY_SESSION=...` que lleva el id de sesión nuevo (rotado). Reenvía + una contraseña incorrecta y en su lugar obtendrás `302 → /login?error`. Ahora + termina la sesión: + + ```bash + curl -i -X POST http://localhost:8080/logout -b "PYFLY_SESSION=" + ``` + + Esperado — `302 → /login?logout` y un `set-cookie` que caduca `PYFLY_SESSION`. + La entrada de la sesión ya no está en el almacén; la cookie ya no resuelve a + nada. + +!!! spring "Equivalencia con Spring" + Estos son los configuradores `formLogin()` y `logout()` de Spring Security. + `FormLoginFilter` se corresponde con `UsernamePasswordAuthenticationFilter`, + `LogoutFilter` con el `LogoutFilter` de Spring, y el mapa `form-login.users` + guiado por configuración es el equivalente de un `UserDetailsManager` en memoria. + La rotación del id de sesión al iniciar sesión coincide con la estrategia de + fijación de sesión `changeSessionId` por defecto de Spring. + +--- + +## Hashing de contraseñas más robusto + +bcrypt es un buen valor por defecto, y es el que Lumen ha usado hasta ahora. Pero +dos cosas son ciertas a la vez: bcrypt es suficientemente bueno hoy, y tarde o +temprano querrás abandonarlo —en favor de Argon2id, el algoritmo que OWASP +recomienda ahora— sin una migración de golpe que obligue a cada usuario a +restablecer su contraseña. El obstáculo es que un hash almacenado no es más que una +cadena; nada en `$2b$12$...` le dice a tu código *qué* algoritmo lo produjo una vez +que tienes más de uno en juego. + +`DelegatingPasswordEncoder` resuelve esto igual que Spring Security: antepone a cada +hash que produce el id del encoder entre llaves —`{argon2}...`, `{bcrypt}...`, +`{pbkdf2}...`, `{scrypt}...`—. Al verificar, lee el prefijo y delega en el encoder +correspondiente, de modo que un único bean de encoder puede *leer* todos los hashes +heredados mientras *escribe* solo el valor por defecto actual. Un valor almacenado +con un prefijo desconocido o ausente nunca coincide: falla de forma cerrada +(fail-closed) por construcción. + +PyFly incluye cuatro adaptadores de `PasswordEncoder` —`BcryptPasswordEncoder`, +`Pbkdf2PasswordEncoder`, `ScryptPasswordEncoder` y `Argon2PasswordEncoder` +(Argon2id; instálalo con `pip install pyfly[argon2]`)— más la factoría +`create_delegating_password_encoder`, que construye un encoder delegador con +**bcrypt** como id por defecto y los otros tres conectados para la verificación. +Para hacer de Argon2id el valor *por defecto* para los hashes nuevos sin dejar de +leer la columna bcrypt existente de Lumen, construye el encoder delegador +directamente y pásale `encoding_id="argon2"`: + +```python +from pyfly.container import bean, configuration +from pyfly.security import ( + Argon2PasswordEncoder, + BcryptPasswordEncoder, + DelegatingPasswordEncoder, + Pbkdf2PasswordEncoder, + ScryptPasswordEncoder, +) + + +@configuration +class PasswordConfig: + + @bean + def password_encoder(self) -> DelegatingPasswordEncoder: + return DelegatingPasswordEncoder( + { + "argon2": Argon2PasswordEncoder(), # valor por defecto preferido por OWASP + "bcrypt": BcryptPasswordEncoder(rounds=12), + "pbkdf2": Pbkdf2PasswordEncoder(), + "scrypt": ScryptPasswordEncoder(), + }, + encoding_id="argon2", + ) +``` + +La ventaja es la **migración transparente en el inicio de sesión**. +`DelegatingPasswordEncoder` expone `upgrade_encoding(stored)`, que devuelve `True` +cuando un hash almacenado fue producido por algo distinto del valor por defecto +actual. Llámalo dentro de `login`, justo después de que la contraseña se verifique, +y vuelve a hashear in situ. Cada usuario se actualiza silenciosamente de bcrypt a +Argon2id la próxima vez que inicie sesión: sin correo de restablecimiento, sin +tiempo de inactividad, sin una segunda columna: + +```python +async def login(self, username: str, password: str) -> str: + user = await self._users.find_by_username(username) + if user is None or not self._encoder.verify(password, user.password_hash): + raise UnauthorizedException( + "Invalid credentials", code="INVALID_CREDENTIALS" + ) + + # Re-hashea de forma transparente las credenciales heredadas ({bcrypt}…) al + # valor por defecto actual ({argon2}…) ahora que tenemos el texto plano y se verificó. + if self._encoder.upgrade_encoding(user.password_hash): + user.password_hash = self._encoder.hash(password) + await self._users.save(user) + + return self._jwt.encode({"sub": str(user.id), "roles": [user.role]}) +``` + +!!! tip "Pruébalo — observa el prefijo y la actualización en el REPL" + ```python + >>> from pyfly.security import create_delegating_password_encoder + >>> enc = create_delegating_password_encoder() # bcrypt por defecto + >>> h = enc.hash("hunter2") + >>> h[:8] + '{bcrypt}' + >>> enc.verify("hunter2", h) + True + >>> enc.upgrade_encoding(h) # ya es el valor por defecto → no necesita actualización + False + >>> enc.upgrade_encoding("{pbkdf2}sha256$600000$...") # heredado → actualiza + True + ``` + + El prefijo `{id}` es lo que hace que esto funcione: la verificación se enruta por + él, y `upgrade_encoding` lo compara con el valor por defecto configurado. + +!!! note "Jerga: Argon2id" + Argon2id es un hash de contraseñas duro en memoria (memory-hard): obliga a un + atacante a gastar *memoria*, no solo CPU, por cada conjetura, lo que neutraliza + las granjas de GPU y ASIC que abaratan a gran escala el crackeo de bcrypt. + `Argon2PasswordEncoder` usa por defecto los parámetros interactivos de OWASP + (coste de tiempo 3, 64 MiB, 4 carriles). + +--- + +## Lumen como su propio servidor de autorización + +Hasta ahora Lumen ha sido un *consumidor* de tokens: acuñando JWTs simples para su +propio inicio de sesión, o validando tokens emitidos por un IdP externo. Hay un +tercer papel que Lumen puede desempeñar: un verdadero **servidor de autorización** +OAuth2 que emite tokens a *otras* partes. Querrás esto en cuanto Lumen tenga más de +una pieza en movimiento. Un worker nocturno de conciliación del libro mayor necesita +llamar a la API de Lumen como sí mismo, sin ningún humano en el bucle. Una +aplicación móvil propia necesita iniciar la sesión de un usuario y actuar en su +nombre. Esos son dos tipos de concesión (grant) de OAuth2 distintos, y +`AuthorizationServer` habla ambos. + +### Servicio a servicio: client_credentials + +El worker de conciliación es un cliente confidencial: posee un secreto y pide un +token en su propio nombre. Registra los clientes de Lumen en un +`InMemoryClientRegistrationRepository` y activa el servidor de autorización en la +configuración: el bean `AuthorizationServer` y un almacén de tokens en memoria se +autoconfiguran por ti: + +```python +from pyfly.container import bean, configuration +from pyfly.security.oauth2 import ( + ClientRegistration, + InMemoryClientRegistrationRepository, +) + + +@configuration +class LumenClientsConfig: + + @bean + def client_repository(self) -> InMemoryClientRegistrationRepository: + return InMemoryClientRegistrationRepository( + # Cliente de máquina — servicio a servicio, sin usuario. + ClientRegistration( + registration_id="lumen-reconciler", + client_id="lumen-reconciler", + client_secret="${RECONCILER_SECRET}", + authorization_grant_type="client_credentials", + scopes=["ledger:read"], + ), + # Aplicación móvil propia — cliente público (sin secreto), solo PKCE. + ClientRegistration( + registration_id="lumen-mobile", + client_id="lumen-mobile", + authorization_grant_type="authorization_code", + redirect_uri="com.lumen.app://callback", + scopes=["openid", "wallet:read", "wallet:deposit"], + ), + ) +``` + +```yaml +pyfly: + security: + oauth2: + authorization-server: + enabled: true + secret: "${AS_SIGNING_SECRET}" # nunca el marcador de posición; HS256 ≥ 32 bytes + issuer: "https://lumen.example.com" + audience: "lumen-backend" + access-token-ttl: 3600 + refresh-token-ttl: 86400 + token-store: + provider: redis # memory | redis | postgres + redis: + url: "redis://localhost:6379/0" +``` + +La superficie HTTP es `AuthorizationServerEndpoints`, que construye las rutas +estándar de OAuth2/OIDC a partir del bean `AuthorizationServer`. Móntalas en la +aplicación: + +```python +from pyfly.security.oauth2 import AuthorizationServer, AuthorizationServerEndpoints + +def register_oauth2(app, context): + server = context.get_bean(AuthorizationServer) + app.router.routes.extend(AuthorizationServerEndpoints(server).routes()) +``` + +Eso expone el conjunto completo: `GET /oauth2/authorize`, `POST /oauth2/par`, +`POST /oauth2/token`, `POST /oauth2/introspect`, `POST /oauth2/revoke`, +`POST /oauth2/register`, `GET /oauth2/jwks`, y los dos documentos de descubrimiento +`GET /.well-known/oauth-authorization-server` y +`GET /.well-known/openid-configuration`. + +!!! tip "Pruébalo — obtén un token de máquina" + El worker se autentica con HTTP Basic y pide el ámbito (scope) para el que fue + registrado: + + ```bash + curl -s -X POST http://localhost:8080/oauth2/token \ + -u lumen-reconciler:$RECONCILER_SECRET \ + -d grant_type=client_credentials \ + -d scope="ledger:read" + ``` + + Esperado — una respuesta de token JSON con `access_token`, + `token_type: "Bearer"`, `expires_in: 3600`, un `refresh_token` y + `scope: "ledger:read"`. Ahora pide un ámbito para el que el cliente **no** estaba + registrado: + + ```bash + curl -s -X POST http://localhost:8080/oauth2/token \ + -u lumen-reconciler:$RECONCILER_SECRET \ + -d grant_type=client_credentials -d scope="ledger:write admin" + ``` + + Esperado — `{"error":"invalid_scope", ...}` con HTTP `400`. Un cliente solo puede + obtener los ámbitos para los que está registrado; los ámbitos no registrados se + rechazan por completo, nunca se devuelven en silencio. (Pedir + `client_credentials` desde un cliente registrado solo para `authorization_code` + se rechaza del mismo modo, con `unauthorized_client`.) + +### De cara al usuario: authorization_code + PKCE + un id_token OIDC + +La aplicación móvil es un cliente público: no incluye ningún secreto, porque +cualquier cosa empotrada en el binario de una app no es un secreto. Su prueba de +posesión es **PKCE**: la app genera un `code_verifier` aleatorio, envía solo su hash +SHA-256 (`code_challenge`) cuando pide un código de autorización, y revela el +verificador solo cuando canjea el código. Un atacante que intercepte el código no +puede canjearlo sin el verificador. + +El flujo tiene dos tramos. Primero la app envía al usuario al endpoint de +autorización: + +``` +GET /oauth2/authorize?response_type=code + &client_id=lumen-mobile + &redirect_uri=com.lumen.app://callback + &scope=openid%20wallet:read + &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM + &code_challenge_method=S256 + &state=xyz +``` + +Si el propietario del recurso aún no está autenticado, el endpoint lo redirige a la +URL de inicio de sesión y vuelve. Una vez lo está, Lumen emite un código de +autorización **de un solo uso** y redirige a +`redirect_uri?code=...&state=xyz&iss=...`. El parámetro `iss` (RFC 9207) permite a +la app confirmar qué servidor respondió: una defensa contra la confusión (mix-up). +Luego la app canjea el código, presentando el verificador: + +```bash +curl -s -X POST http://localhost:8080/oauth2/token \ + -d grant_type=authorization_code \ + -d client_id=lumen-mobile \ + -d code="$CODE" \ + -d redirect_uri="com.lumen.app://callback" \ + -d code_verifier="$VERIFIER" +``` + +La respuesta lleva el `access_token`, un `refresh_token` y —porque la petición +incluía el ámbito `openid`— un **`id_token`** OIDC cuyo `aud` es el id del cliente. +El servidor de autorización impone las barreras de seguridad por ti: el redirect URI +debe coincidir con el registro **exactamente**, PKCE es **obligatorio y debe ser +S256**, y el código es **de un solo uso**. Reenvía un código que ya se había canjeado +y Lumen no se limita a rechazarlo: trata la repetición como un ataque de inyección y +**revoca el token de actualización (refresh token) ya emitido a partir de ese +código**. + +### Rotación de tokens de actualización y detección de reutilización + +Los tokens de actualización (refresh tokens) rotan. Cada vez que la app refresca, +obtiene un *nuevo* token de actualización y el antiguo se marca como consumido: + +```bash +curl -s -X POST http://localhost:8080/oauth2/token \ + -d grant_type=refresh_token -d client_id=lumen-mobile \ + -d refresh_token="$OLD_REFRESH" +``` + +El token consumido no se elimina: se conserva precisamente para poder detectar una +*repetición*. Si se presenta un token de actualización robado después de que el +cliente legítimo ya lo haya rotado, el servidor detecta la reutilización y revoca la +**familia de tokens completa** (el token original y todos sus descendientes), +obligando tanto al atacante como a la víctima a pasar de nuevo por el inicio de +sesión. Un cliente legítimo nunca reenvía un token ya rotado, así que la +reutilización es prueba inequívoca de robo. Tanto el caso de reutilización como una +presentación desde el cliente equivocado lanzan `INVALID_GRANT`. + +### Firma asimétrica y JWKS + +Para los tokens de servicio a servicio, HS256 con un secreto compartido está bien: +Lumen firma y Lumen verifica. Pero en cuanto *otros* servicios validan los tokens de +Lumen, no querrás entregarles tu secreto de firma. Cambia a la firma asimétrica: +Lumen guarda una clave privada, firma con `RS256` (o `ES256` / `PS256`) y publica +solo la clave **pública** correspondiente en `/oauth2/jwks`. Proporciona tú mismo un +bean `AuthorizationServer` para optar por el modo asimétrico (un bean declarado a +mano suprime la autoconfiguración de HS256): + +```python +from pyfly.container import bean, configuration +from pyfly.security.oauth2 import ( + AuthorizationServer, + InMemoryClientRegistrationRepository, + InMemoryTokenStore, +) + + +@configuration +class AsymmetricAuthServerConfig: + + @bean + def authorization_server( + self, clients: InMemoryClientRegistrationRepository + ) -> AuthorizationServer: + return AuthorizationServer( + secret="", # sin uso en la firma asimétrica + client_repository=clients, + token_store=InMemoryTokenStore(), + algorithm="RS256", + private_key=open("lumen-signing-key.pem").read(), + key_id="lumen-2026", # se convierte en el `kid` de la cabecera del JWT + issuer="https://lumen.example.com", + ) +``` + +Ahora `GET /oauth2/jwks` devuelve el conjunto de claves JWK públicas (con el `kid` y +el `alg`), y cualquier servidor de recursos PyFly puede validar los tokens de Lumen +apuntando su `pyfly.security.oauth2.resource-server.jwks-uri` a él: exactamente la +configuración de Keycloak vista antes en este capítulo, pero con Lumen como emisor. +Lumen ha cerrado el círculo: emite los tokens *y* los valida, y las dos mitades no +comparten nada salvo una clave pública. + +!!! spring "Equivalencia con Spring" + `AuthorizationServer` + `AuthorizationServerEndpoints` es el + `spring-authorization-server` de PyFly. Las rutas de los endpoints, los + documentos de descubrimiento, el PKCE obligatorio con S256, la rotación de tokens + de actualización con detección de reutilización y el conjunto de claves de + `/oauth2/jwks` coinciden todos con los valores por defecto de Spring + Authorization Server. + +--- + +## Tokens restringidos al poseedor (sender-constrained) + +Todos los tokens de este capítulo hasta ahora han sido tokens *portadores* (bearer): +quien lo tiene, lo usa. Esa es toda la gracia, y todo el riesgo. Un token portador +que se filtra por un log, un proxy o un dispositivo comprometido es una credencial +totalmente utilizable en manos de un atacante. Para un monedero que mueve dinero, +"quien lo tiene" no es un modelo de seguridad cómodo. La restricción al poseedor +(sender-constraining) lo arregla vinculando el token a una clave que posee el cliente +legítimo, de modo que un token robado es inerte sin esa clave. + +PyFly soporta ambos mecanismos estándar, y la vinculación se transporta en el claim +`cnf` (confirmación) del token: + +- **DPoP** (RFC 9449) — el cliente firma un JWT de *prueba* (proof) nuevo por cada + petición con su clave privada; el token de acceso lleva `cnf.jkt`, la huella + SHA-256 de esa clave (RFC 7638). El servidor de recursos verifica la prueba y que + la huella de su clave coincida con `jkt`. +- **mTLS** (RFC 8705) — el token de acceso lleva `cnf["x5t#S256"]`, la huella del + certificado TLS del cliente. El servidor de recursos la compara con el certificado + presentado en la conexión. + +El servidor de autorización vincula el token automáticamente: si un cliente presenta +una cabecera `DPoP` en la petición de token, Lumen estampa `cnf.jkt` en el token de +acceso emitido. En el lado del servidor de recursos activas la imposición: + +```yaml +pyfly: + security: + oauth2: + resource-server: + enabled: true + jwks-uri: "https://lumen.example.com/oauth2/jwks" + enforce-sender-constraints: true + mtls-cert-header: "x-client-cert" # lo establece tu proxy que termina el TLS +``` + +Con `enforce-sender-constraints: true`, cuando un token lleva un claim `cnf` el +filtro *exige* la prueba correspondiente: un token `cnf.jkt` sin una cabecera de +prueba `DPoP` válida se rechaza como `INVALID_TOKEN`; un token `cnf["x5t#S256"]` cuya +huella no coincida con el certificado de cliente presentado se rechaza igualmente. +Un token portador simple (sin `cnf`) no se ve afectado: la imposición solo entra en +juego para los tokens que se emitieron restringidos al poseedor, de modo que puedes +desplegarla de forma gradual. + +Las primitivas de verificación son públicas si las necesitas directamente: +`DPoPProofValidator`, `confirm_dpop_binding`, `confirm_mtls_binding`, +`jwk_thumbprint`, `certificate_thumbprint` y `access_token_hash`. Para Lumen, la +lección es la proporcional: protege los ámbitos que mueven dinero +(`wallet:deposit`, `wallet:withdraw`) con tokens restringidos al poseedor, y un token +de acceso filtrado deja de ser una retirada de fondos filtrada. + +--- + +## Seguro por defecto + +Un tema recorre todo este capítulo: la opción segura es la que viene por defecto, y +tienes que *desactivarla* explícitamente, no activarla. Eso es deliberado. Los +valores de seguridad por defecto que hay que recordar son valores de seguridad por +defecto que se olvidan. Vale la pena destacar juntos cuatro de los valores de +endurecimiento por defecto de Lumen, porque cada uno cierra un error que, de otro +modo, es fácil llevar a producción. + +**El secreto de marcador de posición falla rápido.** Los valores por defecto de +PyFly incluyen un secreto de firma literal, `change-me-in-production`. Si lo dejas en +su sitio, la raíz de composición **se niega a arrancar**: tanto el `JWTService` +simétrico (cuando su filtro está activado) como el servidor de autorización lanzan +`INSECURE_SIGNING_SECRET` en lugar de firmar tokens que cualquiera podría falsificar. +Y como una clave HS256 más corta que 32 bytes es más débil que el algoritmo al que +alimenta, un secreto HMAC demasiado corto falla del mismo modo con +`WEAK_SIGNING_SECRET`. No hay camino a producción con una clave adivinable. + +**La protección CSRF está activada.** No la activaste tú; viene activada por +defecto. El filtro está *condicionado por cookies* (cookie-gated): solo desafía las +peticiones no seguras que de verdad llevan cookies, de modo que el tráfico de la API +de Lumen, sin estado y con token Bearer, pasa sin tocarse mientras el panel de cara +al navegador queda protegido. Desactívala por servicio con +`pyfly.security.csrf.enabled: false`, o endurécela hasta una imposición estricta y +siempre activa con `pyfly.security.csrf.cookie-gated: false`. + +**PKCE está activado para el flujo de inicio de sesión.** +`ClientRegistration.use_pkce` vale `True` por defecto, los clientes públicos (los que +no tienen secreto) están *obligados* a usarlo de todos modos, y el paso de +autorización del servidor de autorización exige el método S256: no hay forma de +registrar un cliente de authorization-code que se salte la prueba de posesión. + +**La concesión de contraseña del propietario del recurso está desactivada.** El +flujo heredado ROPC (`grant_type=password`), en el que una app recoge la contraseña +del usuario y la cambia por un token, está deshabilitado en los adaptadores de IdP de +Keycloak, Cognito y Azure AD a menos que establezcas explícitamente +`pyfly.idp.allow-password-grant: true`. La respuesta moderna es el flujo +`authorization_code` + PKCE de arriba; ROPC queda detrás de una activación explícita +para la rara migración heredada que todavía la necesita. + +```yaml +# Las desactivaciones, reunidas en un solo lugar — cada una te aparta de un valor por defecto seguro. +pyfly: + security: + csrf: + enabled: false # desactiva CSRF por completo (servicios solo sin estado) + cookie-gated: false # O mantenlo activado, pero imponlo estrictamente para todos + idp: + allow-password-grant: true # reactiva el ROPC heredado (evítalo salvo que te obliguen) +``` + +La cuestión no es que estos valores por defecto nunca puedan relajarse: cada uno de +ellos tiene una desactivación legítima. La cuestión es que relajarlos sea una *línea +visible y deliberada en tu configuración*, revisada como cualquier otro cambio, en +lugar de una salvaguarda que olvidaste añadir en silencio. + +--- + ## Juntándolo todo — la capa de autenticación de Lumen El listado siguiente muestra la conexión completa: adaptador del IDP, filtro JWT, reglas a nivel de URL y un almacén de sesiones en Redis para el panel de administración. diff --git a/book/manuscript/14-security.md b/book/manuscript/14-security.md index a46973cd..aca4617e 100644 --- a/book/manuscript/14-security.md +++ b/book/manuscript/14-security.md @@ -1301,6 +1301,537 @@ When Starlette is present, `IdpAutoConfiguration` also registers an `IdpControll --- +## Logging users in with a form + +Everything so far assumed a machine client: mint a JWT, replay it in the +`Authorization` header, validate it on the way in. That is exactly right for +Lumen's API. But Lumen also has an admin dashboard — a browser — and browsers do +not carry Bearer headers. They carry cookies. For the dashboard you want the old, +boring, correct thing: a username/password form that POSTs to a login URL, and a +session cookie that rides along afterwards. + +PyFly gives you that without writing a controller. `FormLoginFilter` intercepts a +POST to the login URL, authenticates the credentials through a `ProviderManager`, +and — crucially — **rotates the session id** before binding the authenticated +`SecurityContext` to the session. The rotation is the session-fixation defense +from the sessions section, applied automatically at the moment privileges change: +whatever session id an attacker may have planted before login is thrown away and +replaced. The context is stored in the session, and `OAuth2SessionSecurityFilter` +restores it on every later request, so the dashboard stays logged in. + +You declare the users and the URLs in `pyfly.yaml`. Passwords are stored +**pre-hashed** — never plaintext — and the auto-configuration verifies them with +`BcryptPasswordEncoder` through a `DaoAuthenticationProvider`: + +```yaml +pyfly: + security: + enabled: true + form-login: + enabled: true + login-url: /login + username-param: username + password-param: password + success-url: /dashboard + failure-url: /login?error + use-redirect: true + users: + alice: + # bcrypt hash of "hunter2" — generate with BcryptPasswordEncoder.hash + password-hash: "$2b$12$Q9.../iJ8wq6Yk5fJ0bO" + roles: ADMIN + permissions: wallet:read,wallet:create + enabled: true + logout: + enabled: true + logout-url: /logout + success-url: /login?logout + delete-cookies: PYFLY_SESSION + use-redirect: true +``` + +`FormLoginFilter` runs at `HIGHEST_PRECEDENCE + 230`, just after the +session-restoring filter, so a fresh login always overrides any prior anonymous +context. `LogoutFilter` runs right behind it at `+235`: a POST to the logout URL +invalidates the session, resets the request context to +`SecurityContext.anonymous()`, and deletes the cookies you name in +`delete-cookies`. Both filters speak two dialects — set `use-redirect: false` +and they answer with JSON (`{"authenticated": true}` / `204`) instead of a `302`, +which is what you want if the dashboard is a single-page app calling `fetch`. + +The authentication itself is the `DaoAuthenticationProvider` from PyFly's +authentication SPI. It is deliberately careful about one thing: an unknown +username still incurs a password verification against a throw-away dummy hash, so +the login endpoint cannot be used as a username oracle — a wrong password and a +nonexistent user take the same time and raise the same `BadCredentialsException`. +A disabled account (`enabled: false`) raises `DisabledException`. + +!!! tip "Run it — log in with a cookie, then log out" + Start the app and POST the form (note `-d`, not JSON — this is a real HTML + form submission): + + ```bash + curl -i -X POST http://localhost:8080/login \ + -d "username=alice&password=hunter2" + ``` + + Expected — `HTTP/1.1 302 Found`, a `location: /dashboard` header, and a + `set-cookie: PYFLY_SESSION=...` carrying the new (rotated) session id. Replay + a wrong password and you get `302 → /login?error` instead. Now end the + session: + + ```bash + curl -i -X POST http://localhost:8080/logout -b "PYFLY_SESSION=" + ``` + + Expected — `302 → /login?logout` and a `set-cookie` that expires + `PYFLY_SESSION`. The session entry is gone from the store; the cookie no + longer resolves to anything. + +!!! spring "Spring parity" + This is Spring Security's `formLogin()` and `logout()` configurers. + `FormLoginFilter` maps to `UsernamePasswordAuthenticationFilter`, + `LogoutFilter` to Spring's `LogoutFilter`, and the config-driven + `form-login.users` map is the equivalent of an in-memory + `UserDetailsManager`. Session-id rotation on login matches Spring's default + `changeSessionId` session-fixation strategy. + +--- + +## Stronger password hashing + +bcrypt is a fine default, and it is the one Lumen has used so far. But two things +are true at once: bcrypt is good enough today, and you will eventually want to +move off it — to Argon2id, the algorithm OWASP now recommends — without a +flag-day migration that forces every user to reset their password. The obstacle +is that a stored hash is just a string; nothing about `$2b$12$...` tells your code +*which* algorithm produced it once you have more than one in play. + +`DelegatingPasswordEncoder` solves this the way Spring Security does: it prefixes +every hash it produces with the encoder's id in braces — `{argon2}...`, +`{bcrypt}...`, `{pbkdf2}...`, `{scrypt}...`. On verification it reads the prefix +and dispatches to the matching encoder, so a single encoder bean can *read* every +legacy hash while *writing* only the current default. A stored value with an +unknown or missing prefix never matches — fail-closed by construction. + +PyFly ships four `PasswordEncoder` adapters — `BcryptPasswordEncoder`, +`Pbkdf2PasswordEncoder`, `ScryptPasswordEncoder`, and `Argon2PasswordEncoder` +(Argon2id; install with `pip install pyfly[argon2]`) — plus the +`create_delegating_password_encoder` factory, which builds a delegating encoder +with **bcrypt** as the default id and the other three wired in for verification. +To make Argon2id the *default* for new hashes while still reading Lumen's +existing bcrypt column, construct the delegating encoder directly and pass +`encoding_id="argon2"`: + +```python +from pyfly.container import bean, configuration +from pyfly.security import ( + Argon2PasswordEncoder, + BcryptPasswordEncoder, + DelegatingPasswordEncoder, + Pbkdf2PasswordEncoder, + ScryptPasswordEncoder, +) + + +@configuration +class PasswordConfig: + + @bean + def password_encoder(self) -> DelegatingPasswordEncoder: + return DelegatingPasswordEncoder( + { + "argon2": Argon2PasswordEncoder(), # OWASP-preferred default + "bcrypt": BcryptPasswordEncoder(rounds=12), + "pbkdf2": Pbkdf2PasswordEncoder(), + "scrypt": ScryptPasswordEncoder(), + }, + encoding_id="argon2", + ) +``` + +The win is **transparent on-login migration**. `DelegatingPasswordEncoder` +exposes `upgrade_encoding(stored)`, which returns `True` when a stored hash was +produced by anything other than the current default. Call it inside `login`, +right after the password verifies, and re-hash in place. Each user is silently +upgraded from bcrypt to Argon2id the next time they sign in — no reset email, no +downtime, no second column: + +```python +async def login(self, username: str, password: str) -> str: + user = await self._users.find_by_username(username) + if user is None or not self._encoder.verify(password, user.password_hash): + raise UnauthorizedException( + "Invalid credentials", code="INVALID_CREDENTIALS" + ) + + # Transparently re-hash legacy ({bcrypt}…) credentials to the current + # default ({argon2}…) now that we hold the plaintext and it verified. + if self._encoder.upgrade_encoding(user.password_hash): + user.password_hash = self._encoder.hash(password) + await self._users.save(user) + + return self._jwt.encode({"sub": str(user.id), "roles": [user.role]}) +``` + +!!! tip "Run it — see the prefix and the upgrade in the REPL" + ```python + >>> from pyfly.security import create_delegating_password_encoder + >>> enc = create_delegating_password_encoder() # bcrypt default + >>> h = enc.hash("hunter2") + >>> h[:8] + '{bcrypt}' + >>> enc.verify("hunter2", h) + True + >>> enc.upgrade_encoding(h) # already the default → no upgrade needed + False + >>> enc.upgrade_encoding("{pbkdf2}sha256$600000$...") # legacy → upgrade + True + ``` + + The `{id}` prefix is what makes this work: verification routes by it, and + `upgrade_encoding` compares it to the configured default. + +!!! note "Jargon: Argon2id" + Argon2id is a memory-hard password hash — it forces an attacker to spend + *memory*, not just CPU, per guess, which neutralises the GPU and ASIC farms + that make bcrypt cracking cheap at scale. `Argon2PasswordEncoder` defaults to + OWASP's interactive parameters (time cost 3, 64 MiB, 4 lanes). + +--- + +## Lumen as its own authorization server + +Up to now Lumen has been a token *consumer* — minting simple JWTs for its own +login, or validating tokens an external IdP issued. There is a third role Lumen +can play: a real OAuth2 **authorization server** that issues tokens to *other* +parties. You want this the moment Lumen has more than one moving part. A nightly +ledger-reconciliation worker needs to call Lumen's API as itself, with no human +in the loop. A first-party mobile app needs to sign a user in and act on their +behalf. Those are two different OAuth2 grants, and `AuthorizationServer` speaks +both. + +### Service-to-service: client_credentials + +The reconciliation worker is a confidential client: it holds a secret and asks +for a token in its own name. Register Lumen's clients in an +`InMemoryClientRegistrationRepository` and enable the authorization server in +config — the `AuthorizationServer` bean and an in-memory token store are +auto-configured for you: + +```python +from pyfly.container import bean, configuration +from pyfly.security.oauth2 import ( + ClientRegistration, + InMemoryClientRegistrationRepository, +) + + +@configuration +class LumenClientsConfig: + + @bean + def client_repository(self) -> InMemoryClientRegistrationRepository: + return InMemoryClientRegistrationRepository( + # Machine client — service-to-service, no user. + ClientRegistration( + registration_id="lumen-reconciler", + client_id="lumen-reconciler", + client_secret="${RECONCILER_SECRET}", + authorization_grant_type="client_credentials", + scopes=["ledger:read"], + ), + # First-party mobile app — public client (no secret), PKCE only. + ClientRegistration( + registration_id="lumen-mobile", + client_id="lumen-mobile", + authorization_grant_type="authorization_code", + redirect_uri="com.lumen.app://callback", + scopes=["openid", "wallet:read", "wallet:deposit"], + ), + ) +``` + +```yaml +pyfly: + security: + oauth2: + authorization-server: + enabled: true + secret: "${AS_SIGNING_SECRET}" # never the placeholder; HS256 ≥ 32 bytes + issuer: "https://lumen.example.com" + audience: "lumen-backend" + access-token-ttl: 3600 + refresh-token-ttl: 86400 + token-store: + provider: redis # memory | redis | postgres + redis: + url: "redis://localhost:6379/0" +``` + +The HTTP surface is `AuthorizationServerEndpoints`, which builds the standard +OAuth2/OIDC routes off the `AuthorizationServer` bean. Mount them on the app: + +```python +from pyfly.security.oauth2 import AuthorizationServer, AuthorizationServerEndpoints + +def register_oauth2(app, context): + server = context.get_bean(AuthorizationServer) + app.router.routes.extend(AuthorizationServerEndpoints(server).routes()) +``` + +That exposes the full set: `GET /oauth2/authorize`, `POST /oauth2/par`, +`POST /oauth2/token`, `POST /oauth2/introspect`, `POST /oauth2/revoke`, +`POST /oauth2/register`, `GET /oauth2/jwks`, and the two discovery documents +`GET /.well-known/oauth-authorization-server` and +`GET /.well-known/openid-configuration`. + +!!! tip "Run it — get a machine token" + The worker authenticates with HTTP Basic and asks for the scope it was + registered for: + + ```bash + curl -s -X POST http://localhost:8080/oauth2/token \ + -u lumen-reconciler:$RECONCILER_SECRET \ + -d grant_type=client_credentials \ + -d scope="ledger:read" + ``` + + Expected — a JSON token response with `access_token`, `token_type: "Bearer"`, + `expires_in: 3600`, a `refresh_token`, and `scope: "ledger:read"`. Now ask + for a scope the client was **not** registered for: + + ```bash + curl -s -X POST http://localhost:8080/oauth2/token \ + -u lumen-reconciler:$RECONCILER_SECRET \ + -d grant_type=client_credentials -d scope="ledger:write admin" + ``` + + Expected — `{"error":"invalid_scope", ...}` with HTTP `400`. A client can + only ever obtain scopes it is registered for; unregistered scopes are + rejected wholesale, never silently echoed. (Asking for `client_credentials` + from a client registered only for `authorization_code` is rejected the same + way, with `unauthorized_client`.) + +### User-facing: authorization_code + PKCE + an OIDC id_token + +The mobile app is a public client — it ships no secret, because anything baked +into an app binary is not a secret. Its proof of possession is **PKCE**: the app +generates a random `code_verifier`, sends only its SHA-256 hash +(`code_challenge`) when it asks for an authorization code, and reveals the +verifier only when it redeems the code. An attacker who intercepts the code +cannot redeem it without the verifier. + +The flow has two legs. First the app sends the user to the authorize endpoint: + +``` +GET /oauth2/authorize?response_type=code + &client_id=lumen-mobile + &redirect_uri=com.lumen.app://callback + &scope=openid%20wallet:read + &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM + &code_challenge_method=S256 + &state=xyz +``` + +If the resource owner is not yet authenticated, the endpoint bounces them to the +login URL and comes back. Once they are, Lumen issues a **single-use** +authorization code and redirects to `redirect_uri?code=...&state=xyz&iss=...`. +The `iss` parameter (RFC 9207) lets the app confirm which server answered — a +mix-up defense. Then the app redeems the code, presenting the verifier: + +```bash +curl -s -X POST http://localhost:8080/oauth2/token \ + -d grant_type=authorization_code \ + -d client_id=lumen-mobile \ + -d code="$CODE" \ + -d redirect_uri="com.lumen.app://callback" \ + -d code_verifier="$VERIFIER" +``` + +The response carries the `access_token`, a `refresh_token`, and — because the +request included the `openid` scope — an OIDC **`id_token`** whose `aud` is the +client id. The authorization server enforces the safety rails for you: the +redirect URI must match the registration **exactly**, PKCE is **mandatory and +must be S256**, and the code is **single-use**. Replay a code that was already +redeemed and Lumen does not merely reject it — it treats the replay as an +injection attack and **revokes the refresh token already issued from that code**. + +### Refresh rotation and reuse detection + +Refresh tokens rotate. Every time the app refreshes, it gets a *new* refresh +token and the old one is marked consumed: + +```bash +curl -s -X POST http://localhost:8080/oauth2/token \ + -d grant_type=refresh_token -d client_id=lumen-mobile \ + -d refresh_token="$OLD_REFRESH" +``` + +The consumed token is not deleted — it is kept precisely so that a *replay* can +be caught. If a stolen refresh token is presented after the legitimate client has +already rotated it, the server detects the reuse and revokes the **entire token +family** (the original token and every descendant), forcing both the attacker and +the victim back through login. A legitimate client never replays a rotated token, +so reuse is unambiguous evidence of theft. Both the reuse case and a presentation +from the wrong client raise `INVALID_GRANT`. + +### Asymmetric signing and JWKS + +For service-to-service tokens, HS256 with a shared secret is fine — Lumen signs +and Lumen verifies. But once *other* services validate Lumen's tokens, you do not +want to hand them your signing secret. Switch to asymmetric signing: Lumen holds +a private key, signs with `RS256` (or `ES256` / `PS256`), and publishes only the +matching **public** key at `/oauth2/jwks`. Provide an `AuthorizationServer` bean +yourself to opt into asymmetric mode (a hand-declared bean suppresses the +HS256 auto-configuration): + +```python +from pyfly.container import bean, configuration +from pyfly.security.oauth2 import ( + AuthorizationServer, + InMemoryClientRegistrationRepository, + InMemoryTokenStore, +) + + +@configuration +class AsymmetricAuthServerConfig: + + @bean + def authorization_server( + self, clients: InMemoryClientRegistrationRepository + ) -> AuthorizationServer: + return AuthorizationServer( + secret="", # unused for asymmetric signing + client_repository=clients, + token_store=InMemoryTokenStore(), + algorithm="RS256", + private_key=open("lumen-signing-key.pem").read(), + key_id="lumen-2026", # becomes the JWT header `kid` + issuer="https://lumen.example.com", + ) +``` + +Now `GET /oauth2/jwks` returns the public JWK set (with the `kid` and +`alg`), and any PyFly resource server can validate Lumen's tokens by pointing its +`pyfly.security.oauth2.resource-server.jwks-uri` at it — exactly the Keycloak +setup from earlier in this chapter, but with Lumen as the issuer. Lumen has come +full circle: it issues the tokens *and* validates them, and the two halves share +nothing but a public key. + +!!! spring "Spring parity" + `AuthorizationServer` + `AuthorizationServerEndpoints` is PyFly's + `spring-authorization-server`. The endpoint paths, the discovery documents, + mandatory-S256 PKCE, refresh-token rotation with reuse detection, and the + `/oauth2/jwks` key set all match Spring Authorization Server's defaults. + +--- + +## Sender-constrained tokens + +Every token in this chapter so far has been a *bearer* token: whoever holds it, +uses it. That is the whole point — and the whole risk. A bearer token that leaks +from a log, a proxy, or a compromised device is a fully-usable credential in an +attacker's hands. For a wallet that moves money, "whoever holds it" is not a +comfortable security model. Sender-constraining fixes it by binding the token to a +key the legitimate client holds, so a stolen token is inert without that key. + +PyFly supports both standard mechanisms, and the binding is carried in the +token's `cnf` (confirmation) claim: + +- **DPoP** (RFC 9449) — the client signs a fresh per-request *proof* JWT with its + private key; the access token carries `cnf.jkt`, the SHA-256 thumbprint of that + key (RFC 7638). The resource server verifies the proof and that its key's + thumbprint matches `jkt`. +- **mTLS** (RFC 8705) — the access token carries `cnf["x5t#S256"]`, the thumbprint + of the client's TLS certificate. The resource server compares it to the cert + presented on the connection. + +The authorization server binds the token automatically: if a client presents a +`DPoP` header on the token request, Lumen stamps `cnf.jkt` into the issued access +token. On the resource-server side you turn enforcement on: + +```yaml +pyfly: + security: + oauth2: + resource-server: + enabled: true + jwks-uri: "https://lumen.example.com/oauth2/jwks" + enforce-sender-constraints: true + mtls-cert-header: "x-client-cert" # set by your TLS-terminating proxy +``` + +With `enforce-sender-constraints: true`, when a token carries a `cnf` claim the +filter *requires* the matching proof: a `cnf.jkt` token with no valid `DPoP` +proof header is rejected as `INVALID_TOKEN`; a `cnf["x5t#S256"]` token whose +thumbprint does not match the presented client certificate is likewise rejected. +A plain bearer token (no `cnf`) is unaffected — enforcement only kicks in for +tokens that were issued sender-constrained, so you can roll it out gradually. + +The verification primitives are public if you need them directly: +`DPoPProofValidator`, `confirm_dpop_binding`, `confirm_mtls_binding`, +`jwk_thumbprint`, `certificate_thumbprint`, and `access_token_hash`. For Lumen, +the lesson is the proportional one: protect the money-moving scopes +(`wallet:deposit`, `wallet:withdraw`) with sender-constrained tokens, and a +leaked access token stops being a leaked withdrawal. + +--- + +## Secure by default + +A theme runs through this whole chapter: the safe choice is the default, and you +have to *opt out* of it, not opt in. That is deliberate. Security defaults that +must be remembered are security defaults that get forgotten. Four of Lumen's +hardening defaults are worth calling out together, because each one closes a +mistake that is otherwise easy to ship. + +**The placeholder secret fails fast.** PyFly's defaults ship a literal signing +secret, `change-me-in-production`. If you leave it in place, the composition root +**refuses to start** — both the symmetric `JWTService` (when its filter is +enabled) and the authorization server raise `INSECURE_SIGNING_SECRET` rather than +sign tokens anyone could forge. And because an HS256 key shorter than 32 bytes is +weaker than the algorithm it feeds, a too-short HMAC secret fails the same way +with `WEAK_SIGNING_SECRET`. There is no path to production with a guessable key. + +**CSRF protection is on.** You did not enable it; it is enabled by default. The +filter is *cookie-gated*: it only challenges unsafe requests that actually carry +cookies, so Lumen's stateless, Bearer-token API traffic sails through untouched +while the browser-facing dashboard is protected. Opt out per service with +`pyfly.security.csrf.enabled: false`, or tighten to strict, always-on +enforcement with `pyfly.security.csrf.cookie-gated: false`. + +**PKCE is on for the login flow.** `ClientRegistration.use_pkce` defaults to +`True`, public clients (those with no secret) are *forced* to use it regardless, +and the authorization server's authorize step mandates the S256 method — there is +no way to register an authorization-code client that skips proof-of-possession. + +**Resource-owner password grant is off.** The legacy ROPC flow +(`grant_type=password`), where an app collects the user's password and trades it +for a token, is disabled on the Keycloak, Cognito, and Azure AD IdP adapters +unless you explicitly set `pyfly.idp.allow-password-grant: true`. The modern +answer is the `authorization_code` + PKCE flow above; ROPC stays behind an +opt-in for the rare legacy migration that still needs it. + +```yaml +# The opt-outs, gathered in one place — each one moves you OFF a safe default. +pyfly: + security: + csrf: + enabled: false # disable CSRF entirely (stateless-only services) + cookie-gated: false # OR keep it on, but enforce strictly for everyone + idp: + allow-password-grant: true # re-enable legacy ROPC (avoid unless forced) +``` + +The point is not that these defaults can never be relaxed — every one of them has +a legitimate opt-out. The point is that relaxing them is a *visible, deliberate +line in your config*, reviewed like any other change, instead of a safeguard you +silently forgot to add. + +--- + ## Putting it together — Lumen's auth layer The listing below shows the complete wiring: IDP adapter, JWT filter, URL-level rules, and a Redis session store for the admin dashboard. diff --git a/docs/modules/idp.md b/docs/modules/idp.md index a28015ae..c4065fd9 100644 --- a/docs/modules/idp.md +++ b/docs/modules/idp.md @@ -53,6 +53,7 @@ pyfly: idp: enabled: true provider: internal-db # internal-db (default) | keycloak | cognito | azure-ad + allow-password-grant: false # ROPC (grant_type=password) — OFF by default (OAuth 2.1 / RFC 9700) keycloak: base-url: https://keycloak.example.com realm: myrealm @@ -76,6 +77,14 @@ pyfly: | `cognito` / `aws-cognito` | `AwsCognitoIdpAdapter` | | `azure-ad` / `azuread` / `entra` | `AzureAdIdpAdapter` | +> **ROPC is disabled by default.** The Resource Owner Password Credentials grant +> (`grant_type=password`) on the `keycloak`, `cognito`, and `azure-ad` adapters — +> where the app handles the user's raw password — is removed by OAuth 2.1 and +> discouraged by RFC 9700 §2.4. It is refused unless you set +> `pyfly.idp.allow-password-grant=true`. Prefer the `authorization_code` + PKCE +> [OAuth2 login flow](oauth2.md#oauth2-client-login) instead. The first-party +> `internal-db` adapter is unaffected (it verifies passwords locally with bcrypt). + When `starlette` is installed, an `IdpController` bean is also registered, mounting authentication and admin endpoints under `/idp`: diff --git a/docs/modules/oauth2.md b/docs/modules/oauth2.md new file mode 100644 index 00000000..40005ef3 --- /dev/null +++ b/docs/modules/oauth2.md @@ -0,0 +1,1019 @@ +# OAuth 2.1 & OpenID Connect + +PyFly ships a complete, standards-driven OAuth2 / OpenID Connect stack that plays +all three roles in the protocol: a **resource server** that validates bearer +tokens from any mainstream IdP, an **OAuth2 client / OIDC relying party** that logs +users in via the `authorization_code` flow, and a first-party **authorization +server** that issues and manages its own tokens. Every piece follows hexagonal +principles — token stores, client repositories and claim mapping are ports with +swappable adapters — and defaults to the hardened behaviour mandated by +[RFC 9700](https://www.rfc-editor.org/rfc/rfc9700) (OAuth 2.0 Security BCP) and +OAuth 2.1: PKCE, exact redirect matching, refresh-token rotation with reuse +detection, audience restriction, and sender-constrained tokens. + +This guide is the definitive reference for the OAuth2 module. For the surrounding +authentication/authorization machinery (`SecurityContext`, `HttpSecurity`, +`@pre_authorize`, CSRF, password encoding) see the [Security Guide](security.md). + +--- + +## Table of Contents + +- [Overview](#overview) +- [Resource Server](#resource-server) + - [Enable via configuration](#enable-via-configuration) + - [JWKS validation](#jwks-validation) + - [OIDC discovery (issuer-uri)](#oidc-discovery-issuer-uri) + - [Multi-IdP claim mapping (ClaimMappings)](#multi-idp-claim-mapping-claimmappings) + - [Error modes](#error-modes) + - [Resource-server configuration reference](#resource-server-configuration-reference) + - [Programmatic use](#programmatic-use) + - [Opaque tokens (OpaqueTokenIntrospector)](#opaque-tokens-opaquetokenintrospector) +- [OAuth2 Client & Login](#oauth2-client-login) + - [The authorization_code login flow](#the-authorization_code-login-flow) + - [PKCE (on by default)](#pkce-on-by-default) + - [state and nonce](#state-and-nonce) + - [RFC 9207 issuer identification (require_iss)](#rfc-9207-issuer-identification-require_iss) + - [ID token validation](#id-token-validation) + - [Built-in providers](#built-in-providers) + - [Config-driven registrations](#config-driven-registrations) +- [Authorization Server](#authorization-server) + - [Grant types](#grant-types) + - [client_credentials](#client_credentials) + - [refresh_token (rotation + reuse detection)](#refresh_token-rotation-reuse-detection) + - [authorization_code + PKCE](#authorization_code-pkce) + - [OIDC ID tokens](#oidc-id-tokens) + - [Symmetric vs asymmetric signing](#symmetric-vs-asymmetric-signing) + - [HTTP endpoints (AuthorizationServerEndpoints)](#http-endpoints-authorizationserverendpoints) + - [Dynamic Client Registration (RFC 7591)](#dynamic-client-registration-rfc-7591) + - [PAR (RFC 9126) and JAR (RFC 9101)](#par-rfc-9126-and-jar-rfc-9101) + - [Introspection (RFC 7662) & revocation (RFC 7009)](#introspection-rfc-7662-revocation-rfc-7009) + - [Metadata & discovery (RFC 8414 + OIDC)](#metadata-discovery-rfc-8414-oidc) + - [Token stores (memory / redis / postgres)](#token-stores-memory-redis-postgres) + - [Signing-secret hardening](#signing-secret-hardening) +- [Sender-Constrained Tokens (DPoP & mTLS)](#sender-constrained-tokens-dpop-mtls) + - [DPoP (RFC 9449)](#dpop-rfc-9449) + - [mTLS (RFC 8705)](#mtls-rfc-8705) + - [Resource-server enforcement](#resource-server-enforcement) + - [DPoP / mTLS helpers](#dpop-mtls-helpers) +- [Standards & Compliance](#standards-compliance) +- [Configuration Reference](#configuration-reference) + +--- + +## Overview + +```python +from pyfly.security.oauth2 import ( + # Resource server + JWKSTokenValidator, + OpaqueTokenIntrospector, + ClaimMappings, + ResourceServerProperties, + discover_oidc, + # Client & login + ClientRegistration, + ClientRegistrationRepository, + InMemoryClientRegistrationRepository, + OAuth2LoginHandler, + OAuth2SessionSecurityFilter, + google, github, keycloak, + # Authorization server + AuthorizationServer, + AuthorizationServerEndpoints, + TokenStore, + InMemoryTokenStore, +) +``` + +| Role | What it does | Core types | Turn on with | +|---|---|---|---| +| **Resource server** | Validates incoming bearer JWTs against a JWKS endpoint and maps claims to a `SecurityContext` | `JWKSTokenValidator`, `OpaqueTokenIntrospector`, `ClaimMappings` | `pyfly.security.oauth2.resource-server.enabled=true` | +| **Client / OIDC relying party** | Logs users in via the browser `authorization_code` flow against Google / GitHub / Keycloak / any provider | `ClientRegistration`, `OAuth2LoginHandler`, `OAuth2SessionSecurityFilter` | `pyfly.security.oauth2.client.enabled=true` + `pyfly.security.oauth2.login.enabled=true` | +| **Authorization server** | Issues and manages first-party tokens (`client_credentials`, `refresh_token`, `authorization_code`) and exposes the standard OAuth2/OIDC endpoints | `AuthorizationServer`, `AuthorizationServerEndpoints`, `TokenStore` | `pyfly.security.oauth2.authorization-server.enabled=true` | + +The three roles are independent — enable any subset. A typical microservice is a +resource server only; an edge/BFF service adds the client/login role; an internal +identity service runs the authorization server. + +--- + +## Resource Server + +When PyFly acts as a resource server it receives `Authorization: Bearer ` +tokens minted by an external authorization server (Keycloak, Microsoft Entra ID, +AWS Cognito, Auth0, or PyFly's own AS) and validates each one before the request +reaches your handlers. `JWKSTokenValidator` verifies the signature against the +provider's published JSON Web Key Set, checks `iss`, `aud` (when configured) and +`exp` (with clock-skew leeway), and maps the claims onto a `SecurityContext`. + +### Enable via configuration + +The resource-server filter auto-wires when +`pyfly.security.oauth2.resource-server.enabled=true` and `pyjwt` is installed +(`OAuth2ResourceServerAutoConfiguration`). It binds `ResourceServerProperties`, +builds a `JWKSTokenValidator`, and adds an `OAuth2ResourceServerFilter` to the +chain. + +```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; the token aud must match ANY + validate-audience: true # set false for Cognito ACCESS tokens (no aud) + algorithms: "RS256" + clock-skew-seconds: 60 # leeway for iat/nbf/exp (default 60) + jwks-timeout-seconds: 30 + jwks-cache-seconds: 300 + # 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" +``` + +The filter runs at `HIGHEST_PRECEDENCE + 250` and honours `exclude-patterns` +(fnmatch globs) so public paths skip token validation. JWKS key lookup can do +blocking network I/O on a cache miss, so validation is offloaded to a worker +thread (`anyio.to_thread`) to avoid stalling the event loop. The +`Authorization` header is accepted with either the `Bearer` or `DPoP` scheme +(case-insensitive). + +### JWKS validation + +`JWKSTokenValidator.validate(token)` performs the full check and returns the +decoded payload: + +- fetches (and caches) the signing key whose `kid` matches the token header from + the JWKS endpoint (`PyJWKClient`, cached for `jwks-cache-seconds`); +- verifies the signature with one of the allowed `algorithms` (default + `["RS256"]`); +- validates `iss` when an issuer is configured; +- validates `aud` **only** when `audiences` is non-empty and + `validate-audience` is `true` — the token's `aud` must match any configured + audience; +- requires and validates `exp`, applying `clock-skew-seconds` of `leeway` to + `iat` / `nbf` / `exp` (default 60s, matching Spring Security's + `JwtTimestampValidator`). + +A failed check raises `SecurityException(code="INVALID_TOKEN")`. + +### OIDC discovery (issuer-uri) + +Instead of hard-coding a `jwks-uri`, set `issuer-uri` and PyFly performs OIDC +discovery at startup: it GETs `/.well-known/openid-configuration` and +reads `jwks_uri` and the authoritative `issuer` from the document. The discovered +`issuer` is what the validator enforces against the token's `iss` claim. This is +the `discover_oidc(issuer_uri)` helper, which returns `(jwks_uri, issuer)` and +raises `SecurityException(code="OIDC_DISCOVERY_FAILED")` if the document cannot be +fetched or lacks `jwks_uri`. + +```python +from pyfly.security.oauth2 import discover_oidc + +jwks_uri, issuer = discover_oidc("https://accounts.google.com") +# ("https://www.googleapis.com/oauth2/v3/certs", "https://accounts.google.com") +``` + +### Multi-IdP claim mapping (ClaimMappings) + +`ClaimMappings` is a frozen dataclass that drives how a validated payload becomes +a `SecurityContext`. Claim names support **dotted paths** (`realm_access.roles`) +and a single-level `*` **wildcard** that iterates every key at that level +(`resource_access.*.roles`). Paths split on `.` only, so colon-bearing claims like +`cognito:groups` match verbatim. This is what makes the resource server work with +Keycloak, Entra ID (v1.0 + v2.0) and Cognito with zero subclassing. + +| Field | Config key | Default | Maps to | +|---|---|---|---| +| `principal_claims` | `principal-claim-names` | `("oid", "sub")` | `SecurityContext.user_id` (first non-empty wins) | +| `authority_claims` | `authorities-claim-names` | `roles`, `scopes`, `authorities`, `realm_access.roles`, `resource_access.*.roles`, `groups`, `cognito:groups` | `SecurityContext.roles` (collected across all paths, de-duplicated) | +| `scope_claims` | `scope-claim-names` | `("scp", "scope")` | `SecurityContext.permissions` (space-delimited strings split into individual scopes) | +| `authority_prefix` | `authority-prefix` | `""` | prepended to every authority (e.g. `ROLE_` / `SCOPE_` for Spring-style) | +| `attribute_claims` | `attribute-claims` | `()` | copied verbatim (string-coerced) into `SecurityContext.attributes` | + +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` | + +An application that needs bespoke mapping can subclass `JWKSTokenValidator` and +override `_build_context`, registering it as a bean — the auto-config backs off +via `@conditional_on_missing_bean(JWKSTokenValidator)`. + +### Error modes + +`authenticate-error-mode` governs what happens when a token is present but fails +validation: + +| Mode | Missing token | Present but invalid token | +|---|---|---| +| `anonymous` (default) | anonymous `SecurityContext`, request proceeds — the `HttpSecurity` gate / `@pre_authorize` decides | anonymous `SecurityContext`, request proceeds (the gate decides) | +| `401` | falls through to the gate (public endpoints stay reachable) | rejected at the filter with `401 Unauthorized` + `WWW-Authenticate: Bearer error="invalid_token"` (RFC 6750) | + +The `anonymous` default keeps the resource-server filter composable with +permit-all public endpoints; choose `401` when every protected route is a pure +API that should reject bad credentials immediately. + +### Resource-server configuration reference + +All keys nest under `pyfly.security.oauth2.resource-server`: + +| Key | Default | Meaning | +|---|---|---| +| `enabled` | `false` | Activate the resource server | +| `jwks-uri` | `""` | JWKS endpoint URL (skip when using `issuer-uri`) | +| `issuer-uri` | `""` | OIDC discovery base — derives `jwks-uri` + `issuer` | +| `issuer` | `""` | Expected `iss` claim (validated when set) | +| `audiences` | `""` | Comma-separated accepted audiences; `aud` must match any | +| `validate-audience` | `true` | Set `false` to skip `aud` validation (Cognito access tokens) | +| `algorithms` | `RS256` | Comma-separated allowed signing algorithms | +| `clock-skew-seconds` | `60` | Leeway for `iat` / `nbf` / `exp` | +| `jwks-timeout-seconds` | `30` | HTTP timeout for JWKS fetches | +| `jwks-cache-seconds` | `300` | JWK-set cache lifespan | +| `principal-claim-names` | `oid,sub` | Principal (user id) claim search order | +| `authorities-claim-names` | `roles,scopes,authorities,realm_access.roles,resource_access.*.roles,groups,cognito:groups` | Authority/role claim paths | +| `authority-prefix` | `""` | Prefix applied to each authority | +| `scope-claim-names` | `scp,scope` | Scope/permission claim names | +| `attribute-claims` | `""` | Claims copied verbatim into `attributes` | +| `enforce-sender-constraints` | `false` | Require DPoP/mTLS proof when a token carries `cnf` (see [Sender-Constrained Tokens](#sender-constrained-tokens-dpop-mtls)) | +| `mtls-cert-header` | `x-client-cert` | Header carrying the client certificate (mTLS) | +| `exclude-patterns` | `""` | Comma-separated fnmatch globs skipped by the filter | +| `authenticate-error-mode` | `anonymous` | `anonymous` or `401` | + +### Programmatic use + +```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"], + algorithms=["RS256"], + leeway=60, + claim_mappings=ClaimMappings(attribute_claims=("tid",)), +) + +ctx = validator.to_security_context(token) +# SecurityContext(user_id=..., roles=[...], permissions=[...], attributes={...}) + +# Validate once and get both raw claims and the context (e.g. to inspect cnf): +claims, ctx = validator.validate_and_context(token) +``` + +**Constructor parameters:** + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `jwks_uri` | `str` | required | URL of the JWKS endpoint | +| `issuer` | `str \| None` | `None` | Expected `iss` (validated when set) | +| `audiences` | `list[str] \| None` | `None` | Accepted audiences; 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` | +| `claim_mappings` | `ClaimMappings \| None` | multi-IdP defaults | Claim→context mapping | +| `jwks_timeout` | `float` | `30.0` | HTTP timeout (seconds) for JWKS fetches | +| `jwks_cache_seconds` | `int` | `300` | JWK-set cache lifespan | + +### Opaque tokens (OpaqueTokenIntrospector) + +For **opaque** (non-JWT) access tokens, use `OpaqueTokenIntrospector`. The +resource server posts the token — authenticated with its own client credentials — +to the authorization server's RFC 7662 `/introspect` endpoint and maps the +returned claims onto a `SecurityContext` using the same `ClaimMappings` as the JWT +validator. + +```python +from pyfly.security.oauth2 import OpaqueTokenIntrospector, ClaimMappings + +introspector = OpaqueTokenIntrospector( + introspection_uri="https://auth.example.com/oauth2/introspect", + client_id="my-resource-server", + client_secret="rs-secret", + claim_mappings=ClaimMappings(), +) + +ctx = introspector.to_security_context(token) # raises if the token is inactive +``` + +`introspect(token)` returns the raw introspection claims, or raises +`SecurityException(code="INVALID_TOKEN")` if the endpoint reports the token as not +`active` (or the request fails). + +**Source:** `src/pyfly/security/oauth2/resource_server.py`, +`src/pyfly/security/oauth2/properties.py`, +`src/pyfly/web/adapters/starlette/filters/oauth2_resource_filter.py` + +--- + +## OAuth2 Client & Login + +When PyFly acts as an OAuth2 client / OIDC relying party, `OAuth2LoginHandler` +implements the browser-facing `authorization_code` flow end to end: +redirect-to-provider, callback-with-code, token exchange, identity establishment, +and logout. A `ClientRegistration` describes each provider; the +`OAuth2SessionSecurityFilter` restores the logged-in `SecurityContext` from the +HTTP session on subsequent requests (see the +[Security Guide](security.md#authentication-mechanisms)). + +### The authorization_code login flow + +`OAuth2LoginHandler` creates three Starlette routes: + +| Route | Method | Description | +|---|---|---| +| `/oauth2/authorization/{registration_id}` | GET | Redirects the browser to the provider's authorization endpoint with `state`, `nonce` and (when applicable) the PKCE `code_challenge` | +| `/login/oauth2/code/{registration_id}` | GET | Handles the callback: validates `state` and `iss`, exchanges the code for tokens, establishes identity, rotates the session id, and stores the `SecurityContext` | +| `/logout` | POST | Invalidates the HTTP session and redirects to `/` | + +The callback (`_handle_callback`) is the security-critical step. In order it: + +1. validates the `state` parameter against the session value (CSRF / fixation + defense) and consumes it (one-time use); +2. surfaces any provider `error` response as a 400; +3. validates the RFC 9207 `iss` parameter (see + [require_iss](#rfc-9207-issuer-identification-require_iss)); +4. retrieves and consumes the one-time PKCE `code_verifier`; +5. exchanges the code for tokens at `token_uri`, sending the `code_verifier`; +6. establishes identity from the verified `id_token` (preferred) or the + `user_info_uri`; +7. **rotates the session id** (`session.rotate_id()`) to defeat session fixation, + then stores the `SecurityContext` under the session; +8. fails with `401` if no authenticated principal could be determined (no silent + anonymous session). + +### PKCE (on by default) + +PKCE (RFC 7636, S256) is **enabled by default** on the `authorization_code` flow — +`ClientRegistration.use_pkce` defaults to `True`, in line with RFC 9700 / OAuth +2.1, which require PKCE for the authorization code grant for *all* client types. +A **public client** (empty `client_secret`) **always** uses PKCE even if +`use_pkce` is explicitly disabled, because it has no other defense against +authorization-code injection. Set `use_pkce=False` only for a confidential client +talking to an AS that rejects PKCE. + +When PKCE applies, the handler: + +1. generates a high-entropy `code_verifier` and its SHA-256 `code_challenge`; +2. adds `code_challenge` + `code_challenge_method=S256` to the authorization + redirect and stashes the one-time `code_verifier` in the session; +3. sends the stored `code_verifier` on the token exchange. + +No extra wiring is needed — the built-in `google()`, `github()` and `keycloak()` +factories all inherit `use_pkce=True`. + +### state and nonce + +On every authorization request the handler generates and stores a random `state` +(32-byte URL-safe token) and a random OIDC `nonce` in the session. `state` is +validated and consumed on callback (mismatch → 400 `invalid_state`); `nonce` is +bound into the ID token and checked during [ID token validation](#id-token-validation). + +### RFC 9207 issuer identification (require_iss) + +[RFC 9207](https://www.rfc-editor.org/rfc/rfc9207) adds an `iss` parameter to the +authorization response to defend against mix-up attacks. PyFly always **rejects a +mismatch** between the received `iss` and the registration's `issuer_uri`. The +`require_iss` flag (default `False`) additionally makes the parameter +*mandatory* — when `True`, a provider that omits `iss` is rejected. With the +default, `iss` is validated when present but a provider that omits it is +tolerated. + +### ID token validation + +When the token response contains an `id_token` **and** the registration has a +`jwks_uri`, identity is taken from the verified ID token rather than userinfo. The +handler validates the ID token via a `JWKSTokenValidator` configured with the +provider's `jwks_uri`, `issuer = issuer_uri`, and `audiences = [client_id]` (an +OIDC ID token's audience is the client id), and additionally checks that the +token's `nonce` matches the session nonce. Any failure → `401 invalid_id_token`. +Otherwise identity falls back to the `user_info_uri` response. + +### Built-in providers + +Pre-configured factories return a ready-to-use `ClientRegistration`: + +```python +from pyfly.security.oauth2 import google, github, keycloak + +google_reg = google( + client_id="...", client_secret="...", + redirect_uri="https://myapp.com/login/oauth2/code/google", +) +github_reg = github(client_id="...", client_secret="...") +keycloak_reg = keycloak( + client_id="...", client_secret="...", + issuer_uri="https://keycloak.example.com/realms/myrealm", # derives all endpoints +) +``` + +| Factory | `registration_id` | Scopes | Endpoints | +|---|---|---|---| +| `google()` | `google` | `openid`, `profile`, `email` | Google authorize/token/userinfo + `jwks_uri` + `issuer_uri` (full OIDC) | +| `github()` | `github` | `read:user`, `user:email` | GitHub authorize/token/userinfo (no JWKS — identity via userinfo) | +| `keycloak()` | `keycloak` | `openid`, `profile`, `email` | Derived from the realm `issuer_uri` (`/protocol/openid-connect/*`) | + +All three use `authorization_grant_type="authorization_code"` and inherit +`use_pkce=True`. + +### Config-driven registrations + +`OAuth2ClientAutoConfiguration` builds an `InMemoryClientRegistrationRepository` +from `pyfly.security.oauth2.client.registrations.` when +`pyfly.security.oauth2.client.enabled=true`. `OAuth2LoginAutoConfiguration` wires +the `OAuth2LoginHandler` and `OAuth2SessionSecurityFilter` when +`pyfly.security.oauth2.login.enabled=true`. + +```yaml +pyfly: + security: + oauth2: + client: + enabled: true + registrations: + my-app: + client-id: "${OAUTH_CLIENT_ID}" + client-secret: "${OAUTH_CLIENT_SECRET}" + authorization-grant-type: "authorization_code" + redirect-uri: "https://myapp.com/login/oauth2/code/my-app" + scopes: "openid,profile,email" + authorization-uri: "https://provider.example.com/authorize" + token-uri: "https://provider.example.com/token" + user-info-uri: "https://provider.example.com/userinfo" + jwks-uri: "https://provider.example.com/.well-known/jwks.json" + issuer-uri: "https://provider.example.com" + provider-name: "My Provider" + use-pkce: true # default true; opt out per registration + require-iss: false # RFC 9207; iss validated when present regardless + login: + enabled: true +``` + +**`ClientRegistration` fields:** + +| Field | Config key | Default | Description | +|---|---|---|---| +| `registration_id` | (map key) | required | Unique registration identifier | +| `client_id` | `client-id` | required | OAuth2 client id | +| `client_secret` | `client-secret` | `""` | Client secret (empty ⇒ public client ⇒ PKCE forced) | +| `authorization_grant_type` | `authorization-grant-type` | `authorization_code` | Grant type | +| `redirect_uri` | `redirect-uri` | `""` | Callback URI | +| `scopes` | `scopes` | `[]` | Requested scopes (comma-separated or list) | +| `authorization_uri` | `authorization-uri` | `""` | Provider authorization endpoint | +| `token_uri` | `token-uri` | `""` | Provider token endpoint | +| `user_info_uri` | `user-info-uri` | `""` | Provider userinfo endpoint | +| `jwks_uri` | `jwks-uri` | `""` | Provider JWKS (enables ID-token validation) | +| `issuer_uri` | `issuer-uri` | `""` | Provider issuer (also the expected RFC 9207 `iss`) | +| `provider_name` | `provider-name` | `""` | Human-readable name | +| `use_pkce` | `use-pkce` | `True` | Enable PKCE (always forced for public clients) | +| `require_iss` | `require-iss` | `False` | Require the RFC 9207 `iss` parameter | +| `allow_introspection` | — (programmatic) | `False` | Mark a resource-server client permitted to introspect tokens it does not own (RFC 7662) | + +Mount the login routes via `extra_routes`: + +```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()) +``` + +**Source:** `src/pyfly/security/oauth2/client.py`, +`src/pyfly/security/oauth2/login.py` + +--- + +## Authorization Server + +`AuthorizationServer` is a first-party OAuth2 authorization server that issues +JWT access tokens and opaque refresh tokens. It is auto-configured when +`pyfly.security.oauth2.authorization-server.enabled=true` and `pyjwt` is +installed (`OAuth2AuthorizationServerAutoConfiguration`), using the configured +`InMemoryClientRegistrationRepository` and the selected +[token store](#token-stores-memory-redis-postgres). + +```yaml +pyfly: + security: + oauth2: + authorization-server: + enabled: true + secret: "${OAUTH2_SIGNING_SECRET}" # required; must be strong (see hardening) + issuer: "https://auth.myapp.com" + audience: "https://api.myapp.com" # comma-separated or list; omitted when unset + access-token-ttl: 3600 # seconds (default 1h) + refresh-token-ttl: 86400 # seconds (default 24h) + token-store: + provider: memory # memory | redis | postgres +``` + +**Constructor parameters** (for programmatic construction): + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `secret` | `str` | required | HMAC signing key (used for `HS*` algorithms) | +| `client_repository` | `ClientRegistrationRepository` | required | Client lookup | +| `token_store` | `TokenStore` | required | Refresh-token / code / family / PAR storage | +| `access_token_ttl` | `int` | `3600` | Access token lifetime (seconds) | +| `refresh_token_ttl` | `int` | `86400` | Refresh token lifetime (seconds) | +| `issuer` | `str \| None` | `None` | `iss` claim + RFC 9207 `iss` on authorize results | +| `audience` | `str \| list[str] \| None` | `None` | `aud` claim restricting where tokens are valid | +| `algorithm` | `str` | `HS256` | JWS algorithm (`HS*` symmetric, or `RS*`/`PS*`/`ES*` asymmetric) | +| `private_key` | PEM/key object | `None` | Required for asymmetric algorithms | +| `key_id` | `str \| None` | `None` | `kid` in the JWT header and published JWK | +| `allow_dynamic_registration` | `bool` | `False` | Enable RFC 7591 dynamic client registration | +| `registration_access_token` | `str \| None` | `None` | Required initial access token for registration | +| `auth_code_ttl` | `int` | `60` | Authorization-code lifetime (seconds) | + +> The config-driven auto-configuration always builds an **HS256** server. To use +> asymmetric signing, dynamic registration, or a custom auth-code TTL, construct +> `AuthorizationServer` yourself and register it as a bean — +> `@conditional_on_missing_bean(AuthorizationServer)` backs the default off. + +### Grant types + +`AuthorizationServer.token(...)` dispatches on `grant_type`. Client +authentication is enforced first: a **confidential** client (one with a +registered secret) MUST present it (constant-time comparison); a **public** +client (no secret) is permitted only for the `authorization_code` grant, where +PKCE provides proof of possession. An unsupported grant raises +`UNSUPPORTED_GRANT_TYPE` (there is no implicit grant and no resource-owner +password grant). + +### client_credentials + +Machine-to-machine. The client must be registered for the `client_credentials` +grant (`authorization_grant_type == "client_credentials"`) or the request is +rejected with `UNAUTHORIZED_CLIENT` — preventing grant-type confusion. Requested +scopes must be a subset of the client's registered scopes; an unregistered scope +is rejected wholesale with `INVALID_SCOPE` (a client can never mint a more +privileged token just by asking). + +```python +response = await auth_server.token( + grant_type="client_credentials", + client_id="my-service", + client_secret="service-secret", + scope="read write", +) +# {"access_token": "...", "token_type": "Bearer", "expires_in": 3600, +# "refresh_token": "...", "scope": "read write"} +``` + +### refresh_token (rotation + reuse detection) + +Refresh tokens are **rotated** on every use: the presented token is marked +consumed (but retained), and a new refresh token is issued in the same *family*. +PyFly implements full **reuse detection** per RFC 9700 / OAuth 2.1: presenting an +already-rotated (used) refresh token is treated as theft and **revokes the entire +token family** (`INVALID_GRANT`). A token whose family was already revoked is +likewise refused. + +```python +new_response = await auth_server.token( + grant_type="refresh_token", + client_id="my-service", + client_secret="service-secret", + refresh_token=response["refresh_token"], +) +``` + +### authorization_code + PKCE + +The authorization code grant is split across `authorize()` (issues the code) and +`token()` (redeems it). `authorize()` enforces the OAuth 2.1 / RFC 9700 hard +requirements: + +- **exact redirect-URI match** — `redirect_uri` must equal the registration's + exactly, or `INVALID_REDIRECT_URI` (never redirected back to the client); +- only `response_type=code` (`UNSUPPORTED_RESPONSE_TYPE` otherwise — no implicit); +- requested scopes must be a subset of the registration's (`INVALID_SCOPE`); +- **mandatory PKCE** — a `code_challenge` is required and the method must be + `S256` (`INVALID_REQUEST` otherwise). + +```python +result = await auth_server.authorize( + client_id="web-app", + redirect_uri="https://app.example.com/callback", + user_id="alice", # already-authenticated resource owner + scope="openid profile", + state="xyz", + code_challenge="", + code_challenge_method="S256", + nonce="", +) +# {"code": "...", "redirect_uri": "...", "state": "xyz", "iss": "https://auth.myapp.com"} + +tokens = await auth_server.token( + grant_type="authorization_code", + client_id="web-app", + client_secret="web-secret", + code=result["code"], + redirect_uri="https://app.example.com/callback", + code_verifier="", +) +``` + +The code is **single-use**: redeeming a code marks it consumed and remembers the +refresh token it issued. Replaying a used code is treated as injection — any +refresh token already issued from it is revoked and the request fails with +`INVALID_GRANT`. The code also expires after `auth_code_ttl` (default 60s), is +bound to the issuing client, and PKCE verification (`S256(code_verifier) == +code_challenge`) is mandatory. + +### OIDC ID tokens + +When the redeemed code's scope contains `openid`, the token response also +includes an OIDC `id_token` (`aud = client_id`, with `iss`, `iat`, `exp`, and the +`nonce` captured at authorization time), signed with the server's algorithm. + +### Symmetric vs asymmetric signing + +By default the server signs with **HS256** using `secret`. For asymmetric signing +(so resource servers can verify tokens via a public JWKS), construct it with an +asymmetric `algorithm` and a `private_key`: + +```python +from pyfly.security.oauth2 import AuthorizationServer, InMemoryTokenStore + +auth_server = AuthorizationServer( + secret="unused-for-asymmetric", + client_repository=client_repo, + token_store=InMemoryTokenStore(), + issuer="https://auth.myapp.com", + algorithm="RS256", # or RS384/RS512, PS256/.., ES256/ES384/ES512 + private_key=pem_private_key, # PEM str/bytes or a cryptography key object + key_id="key-1", +) + +auth_server.jwks() +# {"keys": [{"kty": "RSA", "use": "sig", "alg": "RS256", "kid": "key-1", ...}]} +``` + +`jwks()` returns the public JWK Set for asymmetric algorithms (the `kid` is +included when set) and `{"keys": []}` for HMAC. The `/oauth2/jwks` endpoint +serves this document. + +### HTTP endpoints (AuthorizationServerEndpoints) + +`AuthorizationServerEndpoints(server, login_url="/login")` exposes the server as +Starlette routes. Mount them via `extra_routes`: + +```python +from pyfly.security.oauth2 import AuthorizationServerEndpoints + +endpoints = AuthorizationServerEndpoints(auth_server, login_url="/login") +app = create_app(title="Auth Server", context=context, extra_routes=endpoints.routes()) +``` + +| Route | Method | RFC | Purpose | +|---|---|---|---| +| `/oauth2/authorize` | GET | 6749 §4.1 | Authorization endpoint; bounces to `login_url` if the resource owner is not authenticated, then issues a code (resolving PAR `request_uri` / JAR `request` first) | +| `/oauth2/par` | POST | 9126 | Pushed Authorization Request; client-authenticated; returns a one-time `request_uri` | +| `/oauth2/token` | POST | 6749 §3.2 | Token endpoint; client credentials via HTTP Basic or form params; binds DPoP `cnf.jkt` when a `DPoP` proof header is present | +| `/oauth2/introspect` | POST | 7662 | Token introspection; client-authenticated | +| `/oauth2/revoke` | POST | 7009 | Token revocation; client-authenticated; always responds `200` | +| `/oauth2/register` | POST | 7591 | Dynamic client registration | +| `/oauth2/jwks` | GET | 7517 | Public JWK Set (asymmetric signing) | +| `/.well-known/oauth-authorization-server` | GET | 8414 | Authorization server metadata | +| `/.well-known/openid-configuration` | GET | — | OIDC discovery document | + +Authorization-endpoint errors that may **not** be redirected to the client +(`INVALID_CLIENT`, `INVALID_REDIRECT_URI`) are returned as a 400 directly; safe +errors (`INVALID_SCOPE`, `UNSUPPORTED_RESPONSE_TYPE`, `INVALID_REQUEST`) are +redirected back as `error` parameters (with `state` echoed). Token/management +errors map to a 400, except `INVALID_CLIENT`, which is a `401` with +`WWW-Authenticate: Basic realm="oauth2"`. + +### Dynamic Client Registration (RFC 7591) + +`POST /oauth2/register` registers a client at runtime. It requires the server to +be built with `allow_dynamic_registration=True` and a repository that supports +`add()` (e.g. `InMemoryClientRegistrationRepository`); otherwise `register_client` +raises `REGISTRATION_DISABLED` / `REGISTRATION_UNSUPPORTED` (returned as 403). The +server generates the `client_id` and `client_secret` and returns RFC 7591 +metadata. If a `registration_access_token` is configured, the request MUST present +it as a bearer token (RFC 7591 §3) or it is rejected with `401`. + +```python +auth_server = AuthorizationServer( + secret="...", client_repository=repo, token_store=InMemoryTokenStore(), + allow_dynamic_registration=True, + registration_access_token="initial-access-token", # optional gate +) +``` + +### PAR (RFC 9126) and JAR (RFC 9101) + +- **PAR** — a client POSTs its authorization parameters to `/oauth2/par` + (client-authenticated) and receives a one-time `request_uri` + (`urn:ietf:params:oauth:request_uri:...`, 90-second TTL). It then calls + `/oauth2/authorize?request_uri=...`; the server consumes the stored params + (one-time use, bound to the client) and proceeds. This keeps authorization + parameters off the front channel. +- **JAR** — alternatively the client passes a signed `request` object (a JWT + signed with its client secret, HS256) to `/oauth2/authorize`. The server + verifies it via `verify_request_object` (confidential clients only) and merges + its claims into the request parameters. + +### Introspection (RFC 7662) & revocation (RFC 7009) + +`/oauth2/introspect` reports whether a token is `active`. Access tokens are +self-contained JWTs (signature-verified); refresh tokens are looked up in the +store and are active only if present, unused, unexpired, and their family is still +active. A client may introspect only **its own** tokens unless its registration +sets `allow_introspection=True` — so one client cannot scan another's tokens +(`introspect(..., allow_any_client=...)`). + +`/oauth2/revoke` revokes a refresh token and, when known, its whole rotation +family. Per RFC 7009 §2.1 only the owning client may revoke a token; per §2.2 the +endpoint always returns `200` regardless of whether the token existed. + +### Metadata & discovery (RFC 8414 + OIDC) + +`/.well-known/oauth-authorization-server` and `/.well-known/openid-configuration` +publish the server metadata, including: + +- `response_types_supported`: `["code"]` (no implicit); +- `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"]`; +- the OIDC document additionally advertises `id_token_signing_alg_values_supported` + (the server's signing algorithm), `subject_types_supported`, `scopes_supported` + and `claims_supported`. + +### Token stores (memory / redis / postgres) + +The `TokenStore` port persists refresh tokens, authorization codes, rotation +families and PAR requests: + +```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: ... +``` + +`OAuth2AuthorizationServerAutoConfiguration._build_token_store()` selects the +backend from `pyfly.security.oauth2.token-store.provider` (case-insensitive): + +| Provider | Adapter | Persistence | When to use | +|---|---|---|---| +| `memory` (default) | `InMemoryTokenStore` | Process-local; **lost on restart**, not shared across instances | Development / testing, single instance | +| `redis` | `RedisTokenStore` (`pyfly.security.adapters.redis_token_store`) | Cross-instance, fast distributed revocation; tokens self-evict at `refresh-token-ttl` | Multi-instance servers wanting fast revocation | +| `postgres` | `PostgresTokenStore` (`pyfly.security.adapters.postgres_token_store`) | Durable + auditable in a SQL table | Multi-instance servers needing durable, auditable storage | + +```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 +``` + +The Redis adapter is wired only when the `redis.asyncio` driver is available (it +falls back to `InMemoryTokenStore` otherwise); the Postgres adapter resolves a +SQLAlchemy `AsyncEngine` bean from the container. Both are hexagonal — the +client/engine is injected by the composition root, never imported at module +scope. + +### Signing-secret hardening + +The authorization server refuses to start with an insecure signing secret. At the +composition root, `_resolve_signing_secret` reads +`pyfly.security.oauth2.authorization-server.secret` and: + +- raises `SecurityException(code="INSECURE_SIGNING_SECRET")` if the secret is unset + (i.e. the built-in placeholder `change-me-in-production` would be used); +- raises `SecurityException(code="WEAK_SIGNING_SECRET")` if, for an HMAC (`HS*`) + algorithm, the key is shorter than 32 bytes (RFC 7518 §3.2 requires a key at + least as long as the hash output — 256 bits for HS256). + +Generate a strong value: + +```bash +python -c "import secrets; print(secrets.token_urlsafe(48))" +``` + +The same hardening guards the symmetric `JWTService` secret, but **only** when +`pyfly.security.jwt.filter.enabled=true` — a resource-server-only app (which +verifies via JWKS and never signs symmetric tokens) is not forced to invent a +secret. + +**Error codes** (raised as `SecurityException`, mapped to OAuth2 errors at the +endpoint layer): + +| Code | Cause | +|---|---| +| `INVALID_CLIENT` | Unknown client id or wrong secret (→ 401 at the endpoint) | +| `INVALID_REQUEST` | Missing/invalid parameter (e.g. no refresh token, missing PKCE challenge) | +| `INVALID_GRANT` | Invalid/expired/replayed code or refresh token; family revoked | +| `INVALID_SCOPE` | Requested scope not registered for the client | +| `INVALID_REDIRECT_URI` | `redirect_uri` does not exactly match the registration | +| `UNAUTHORIZED_CLIENT` | Client not authorized for the requested grant | +| `UNSUPPORTED_GRANT_TYPE` | Grant type not supported | +| `UNSUPPORTED_RESPONSE_TYPE` | `response_type` other than `code` | +| `REGISTRATION_DISABLED` / `REGISTRATION_UNSUPPORTED` | Dynamic registration off / repository cannot add | + +**Source:** `src/pyfly/security/oauth2/authorization_server.py`, +`src/pyfly/security/oauth2/endpoints.py`, +`src/pyfly/security/auto_configuration.py` + +--- + +## Sender-Constrained Tokens (DPoP & mTLS) + +A plain bearer token can be replayed by anyone who steals it. Sender-constraining +binds the token to a key the legitimate client holds, so a stolen token alone is +useless. PyFly supports both standard mechanisms: **DPoP** (RFC 9449) and **mTLS** +(RFC 8705), via a `cnf` (confirmation) claim on the access token. + +### DPoP (RFC 9449) + +The client signs a per-request *proof* JWT (`typ: dpop+jwt`) with its private key +and sends it in a `DPoP` header. When the client presents a DPoP proof on the +token request, the authorization server's `/oauth2/token` endpoint validates it +and binds the issued access token to the client's key via `cnf.jkt` — the RFC 7638 +SHA-256 JWK thumbprint. + +`DPoPProofValidator.validate(proof, http_method=..., http_url=..., access_token=...)` +verifies the proof and returns the bound thumbprint, checking that: + +- `typ` is `dpop+jwt` and `alg` is asymmetric (`RS*`/`ES*`/`PS*`/`Ed*`); +- the embedded `jwk` is present and contains **no** private material; +- the signature verifies against that `jwk`; +- `htm` matches the request method and `htu` matches the request URL (normalized + to `scheme://host/path`); +- `iat` is within `max_age_seconds` (default 60); +- `jti` is unseen, when a `replay_cache` is supplied; +- `ath` equals `base64url(SHA-256(access_token))`, when an access token is given. + +### mTLS (RFC 8705) + +The access token carries `cnf["x5t#S256"]`, the SHA-256 thumbprint of the client +certificate. The resource server compares it to the certificate the client +presents (forwarded by the TLS-terminating proxy in the configured header). + +### Resource-server enforcement + +The resource server enforces proof-of-possession when **both** +`pyfly.security.oauth2.resource-server.enforce-sender-constraints=true` **and** the +validated token actually carries a `cnf` claim. Plain bearer tokens (no `cnf`) are +unaffected, so enabling enforcement is non-breaking for callers that present +ordinary tokens. + +```yaml +pyfly: + security: + oauth2: + resource-server: + enabled: true + issuer-uri: "https://auth.myapp.com" + enforce-sender-constraints: true + mtls-cert-header: "x-client-cert" # header carrying the client cert (URL-decoded) +``` + +On a `cnf.jkt` token the filter requires a `DPoP` header, validates the proof +(method, URL, `ath`) and asserts the proof's thumbprint equals `cnf.jkt` +(`confirm_dpop_binding`). On a `cnf["x5t#S256"]` token it requires the +`mtls-cert-header`, URL-decodes it, and asserts the certificate thumbprint equals +`cnf["x5t#S256"]` (`confirm_mtls_binding`). A missing proof/certificate, or a +mismatch, fails as `INVALID_TOKEN` (rejected outright in `401` error mode, or +yielding an anonymous context in `anonymous` mode). + +### DPoP / mTLS helpers + +`pyfly.security.oauth2.dpop` exposes the building blocks: + +| Symbol | Purpose | +|---|---| +| `DPoPProofValidator` | Validate a DPoP proof JWT (with optional `jti` replay cache) and return its `jkt` | +| `jwk_thumbprint(jwk)` | RFC 7638 JWK SHA-256 thumbprint (RSA/EC/OKP) | +| `access_token_hash(token)` | The DPoP `ath` value: `base64url(SHA-256(token))` | +| `confirm_dpop_binding(claims, jkt)` | Assert the token's `cnf.jkt` matches `jkt` | +| `certificate_thumbprint(cert)` | RFC 8705 `x5t#S256` thumbprint of a PEM/DER cert | +| `confirm_mtls_binding(claims, cert)` | Assert the token's `cnf["x5t#S256"]` matches the cert | + +**Source:** `src/pyfly/security/oauth2/dpop.py`, +`src/pyfly/web/adapters/starlette/filters/oauth2_resource_filter.py` + +--- + +## Standards & Compliance + +PyFly's OAuth2 defaults follow [RFC 9700](https://www.rfc-editor.org/rfc/rfc9700) +(OAuth 2.0 Security Best Current Practice) and OAuth 2.1. + +| Requirement | How PyFly satisfies it | +|---|---| +| **PKCE for the authorization code grant** | AS `authorize()` *requires* a `code_challenge` and only accepts `S256`. The OAuth2 client enables PKCE by default (`use_pkce=True`) and **forces** it for public clients regardless of the flag. | +| **Exact redirect-URI matching** | `authorize()` rejects any `redirect_uri` that is not character-for-character equal to the registration (`INVALID_REDIRECT_URI`, never redirected). | +| **No implicit grant** | Only `response_type=code` is accepted (`UNSUPPORTED_RESPONSE_TYPE`); metadata advertises `response_types_supported: ["code"]`. | +| **No resource-owner password (ROPC)** | The AS implements no password grant. The IdP module's ROPC (`grant_type=password`) against keycloak/cognito/azure-ad is disabled unless `pyfly.idp.allow-password-grant=true`. | +| **Refresh-token rotation** | Every refresh use rotates the token within its family. | +| **Refresh-token reuse / replay detection** | Replaying a used refresh token revokes the entire token family (`INVALID_GRANT`). | +| **Authorization-code single use + injection defense** | Codes are single-use; replaying a used code revokes any refresh token already issued from it. | +| **Sender-constrained tokens** | DPoP (`cnf.jkt`, RFC 9449) and mTLS (`cnf["x5t#S256"]`, RFC 8705), enforced by the resource server when `enforce-sender-constraints` is on. | +| **Audience restriction** | The AS emits an `aud` claim when `audience` is set; the resource server validates `aud` against its configured `audiences`. | +| **Issuer identification (mix-up defense)** | RFC 9207 `iss` on authorize responses; the client validates `iss` (mandatory with `require_iss`); the resource server validates the token `iss`. | +| **Client authentication** | Constant-time secret comparison; empty credentials never authenticate; public clients are restricted to the `authorization_code` grant. | +| **Strong token-signing keys** | Startup fails on the placeholder secret or an HMAC key shorter than 32 bytes (RFC 7518 §3.2). | +| **PAR / JAR** | Pushed Authorization Requests (RFC 9126) and signed request objects (RFC 9101) keep authorization parameters off the front channel. | + +--- + +## Configuration Reference + +Every key below nests under `pyfly:`. Defaults reflect the source. + +**Resource server** — `pyfly.security.oauth2.resource-server.*` + +| Key | Default | Meaning | +|---|---|---| +| `enabled` | `false` | Activate the resource server | +| `jwks-uri` | `""` | JWKS endpoint URL | +| `issuer-uri` | `""` | OIDC discovery base (derives `jwks-uri` + `issuer`) | +| `issuer` | `""` | Expected `iss` claim | +| `audiences` | `""` | Comma-separated accepted audiences | +| `validate-audience` | `true` | Skip `aud` validation when `false` | +| `algorithms` | `RS256` | Allowed signing algorithms | +| `clock-skew-seconds` | `60` | Leeway for `iat`/`nbf`/`exp` | +| `jwks-timeout-seconds` | `30` | JWKS fetch HTTP timeout | +| `jwks-cache-seconds` | `300` | JWK-set cache lifespan | +| `principal-claim-names` | `oid,sub` | Principal claim search order | +| `authorities-claim-names` | `roles,scopes,authorities,realm_access.roles,resource_access.*.roles,groups,cognito:groups` | Authority/role claim paths | +| `authority-prefix` | `""` | Prefix applied to each authority | +| `scope-claim-names` | `scp,scope` | Scope/permission claim names | +| `attribute-claims` | `""` | Claims copied into `attributes` | +| `enforce-sender-constraints` | `false` | Require DPoP/mTLS proof for `cnf` tokens | +| `mtls-cert-header` | `x-client-cert` | Header carrying the client certificate | +| `exclude-patterns` | `""` | Fnmatch globs skipped by the filter | +| `authenticate-error-mode` | `anonymous` | `anonymous` or `401` | + +**Client & login** — `pyfly.security.oauth2.client.*` / `pyfly.security.oauth2.login.*` + +| Key | Default | Meaning | +|---|---|---| +| `client.enabled` | `false` | Build the client registration repository | +| `client.registrations..client-id` | — | OAuth2 client id | +| `client.registrations..client-secret` | `""` | Client secret (empty ⇒ public client) | +| `client.registrations..authorization-grant-type` | `authorization_code` | Grant type | +| `client.registrations..redirect-uri` | `""` | Callback URI | +| `client.registrations..scopes` | `""` | Requested scopes (comma-separated or list) | +| `client.registrations..authorization-uri` | `""` | Provider authorization endpoint | +| `client.registrations..token-uri` | `""` | Provider token endpoint | +| `client.registrations..user-info-uri` | `""` | Provider userinfo endpoint | +| `client.registrations..jwks-uri` | `""` | Provider JWKS (enables ID-token validation) | +| `client.registrations..issuer-uri` | `""` | Provider issuer / expected RFC 9207 `iss` | +| `client.registrations..provider-name` | `""` | Human-readable name | +| `client.registrations..use-pkce` | `true` | Enable PKCE (forced for public clients) | +| `client.registrations..require-iss` | `false` | Require the RFC 9207 `iss` parameter | +| `login.enabled` | `false` | Wire `OAuth2LoginHandler` + `OAuth2SessionSecurityFilter` | + +**Authorization server** — `pyfly.security.oauth2.authorization-server.*` / `pyfly.security.oauth2.token-store.*` + +| Key | Default | Meaning | +|---|---|---| +| `authorization-server.enabled` | `false` | Activate the authorization server | +| `authorization-server.secret` | (none — required) | HMAC signing secret (hardened at startup) | +| `authorization-server.issuer` | (unset) | `iss` claim + RFC 9207 `iss` | +| `authorization-server.audience` | (unset) | `aud` claim (comma-separated or list) | +| `authorization-server.access-token-ttl` | `3600` | Access token lifetime (seconds) | +| `authorization-server.refresh-token-ttl` | `86400` | Refresh token lifetime (seconds) | +| `token-store.provider` | `memory` | `memory`, `redis`, or `postgres` | +| `token-store.redis.url` | falls back to `pyfly.session.redis.url`, then `redis://localhost:6379/0` | Redis URL (redis provider) | + +**IdP module** — `pyfly.idp.*` (external identity providers; see ROPC note above) + +| Key | Default | Meaning | +|---|---|---| +| `enabled` | `false` | Activate the IdP module | +| `provider` | `internal-db` | `internal-db`, `keycloak`, `cognito`, or `azure-ad` | +| `allow-password-grant` | `false` | Permit ROPC (`grant_type=password`) against an external IdP | +| `keycloak.base-url` / `keycloak.realm` / `keycloak.client-id` / `keycloak.client-secret` | `""` | Keycloak connection | +| `cognito.user-pool-id` / `cognito.client-id` / `cognito.region` / `cognito.client-secret` | `""` | AWS Cognito connection | +| `azure.tenant-id` / `azure.client-id` / `azure.client-secret` | `""` | Microsoft Entra (Azure AD) connection | + +--- + +**See also:** [Security Guide](security.md) for `SecurityContext`, +`HttpSecurity`, method-level security, CSRF, and password encoding. diff --git a/docs/modules/security.md b/docs/modules/security.md index 037df1cd..e8d421c7 100644 --- a/docs/modules/security.md +++ b/docs/modules/security.md @@ -1,6 +1,8 @@ # Security Guide -The PyFly security module provides a complete authentication and authorization system built around JWT tokens, password hashing, a request-scoped security context, middleware for automatic token processing, and a decorator for role- and permission-based access control. Like all PyFly modules, it follows hexagonal principles: the password encoder is defined as a protocol (port) with a bcrypt adapter, and the security context is a plain dataclass with no framework coupling. +The PyFly security module is a full Spring-Security-style stack for async Python: a request-scoped `SecurityContext`, URL-level (`HttpSecurity`) and method-level (`@pre_authorize`/`@post_authorize`/`@pre_filter`/`@post_filter`) authorization, pluggable authentication mechanisms (form login, HTTP Basic, X.509, a `UserDetailsService`/`AuthenticationManager` SPI, run-as), password encoders (bcrypt, PBKDF2, scrypt, Argon2 behind a delegating encoder), CSRF protection, security headers, and a complete OAuth 2.1 / OpenID Connect implementation (resource server, client & login, and a full authorization server). Like all PyFly modules it follows hexagonal principles, with ports (protocols) and swappable adapters, and a `SecurityContext` that is a plain dataclass with no framework coupling. + +> The OAuth 2.1 / OIDC surface (resource server, client/login, authorization server, DPoP/mTLS, dynamic client registration, PAR/JAR, introspection, discovery) is large enough to have its own page — see the **[OAuth 2.1 & OpenID Connect guide](oauth2.md)**. --- @@ -25,6 +27,7 @@ The PyFly security module provides a complete authentication and authorization s - [PasswordEncoder Protocol](#passwordencoder-protocol) - [BcryptPasswordEncoder](#bcryptpasswordencoder) - [Custom Password Encoders](#custom-password-encoders) + - [Delegating & Modern Encoders](#delegating-modern-encoders) - [SecurityMiddleware](#securitymiddleware) - [How It Works](#how-the-middleware-works) - [Excluding Paths](#excluding-paths) @@ -45,26 +48,24 @@ The PyFly security module provides a complete authentication and authorization s - [Building URL-Level Access Rules](#building-url-level-access-rules) - [Access Rule Types](#access-rule-types) - [HttpSecurityFilter](#httpsecurityfilter) - - [Integration with create_app()](#integration-with-create_app-1) + - [Integration with create_app()](#integration-with-create_app_1) - [Method-Level Security](#method-level-security) - - [@pre_authorize](#pre_authorize--check-before-execution) - - [@post_authorize](#post_authorize--check-after-execution) + - [@pre_authorize](#pre_authorize-check-before-execution) + - [@post_authorize](#post_authorize-check-after-execution) - [Expression Vocabulary](#expression-vocabulary) - [Method Arguments and returnObject](#method-arguments-and-returnobject) - [Role Hierarchy](#role-hierarchy) -- [OAuth2](#oauth2) - - [OAuth2 Resource Server (JWKS)](#oauth2-resource-server-jwks) - - [OAuth2 Client Registration](#oauth2-client-registration) - - [Built-in Provider Factories](#built-in-provider-factories) - - [ClientRegistrationRepository](#clientregistrationrepository) - - [OAuth2 Authorization Server](#oauth2-authorization-server) - - [Issuing Tokens](#issuing-tokens) - - [TokenStore Protocol](#tokenstore-protocol) - - [Error Codes](#error-codes) - - [OAuth2 Login Flow](#oauth2-login-flow) - - [OAuth2LoginHandler](#oauth2loginhandler) - - [OAuth2SessionSecurityFilter](#oauth2sessionsecurityfilter) - - [Login Flow Configuration Example](#login-flow-configuration-example) +- [Authentication Mechanisms](#authentication-mechanisms) + - [UserDetailsService SPI](#userdetails-and-the-userdetailsservice-spi) + - [AuthenticationManager](#authenticationmanager-providermanager-and-daoauthenticationprovider) + - [Form Login](#form-login) + - [HTTP Basic](#http-basic) + - [X.509 Client Certificates](#x509-client-certificate-authentication) + - [Logout](#logout) + - [switch-user / run-as](#switch-user-run-as-impersonation) +- [Security Headers](#security-headers) +- [OAuth 2.1 & OpenID Connect](#oauth-21-openid-connect) — see the [OAuth2 guide](oauth2.md) +- [Secure-by-Default & Hardening](#secure-by-default-hardening) - [Exception Hierarchy](#exception-hierarchy) - [Auto-Configuration](#auto-configuration) - [Putting It All Together](#putting-it-all-together) @@ -98,35 +99,47 @@ The security module consists of the following components: | `HttpSecurityFilter` | `pyfly.web.adapters.starlette.filters.http_security_filter` | Evaluates HttpSecurity rules at filter layer | | `OAuth2LoginHandler` | `pyfly.security.oauth2.login` | Browser-facing authorization_code login flow | | `OAuth2SessionSecurityFilter` | `pyfly.security.oauth2.session_security_filter` | Restores SecurityContext from HTTP session | +| `UserDetailsService` | `pyfly.security.user_details` | Credential-lookup SPI (`InMemoryUserDetailsService`, `SqlUserDetailsService`) | +| `ProviderManager` / `DaoAuthenticationProvider` | `pyfly.security.authentication` | `AuthenticationManager` SPI | +| `FormLoginFilter` / `LogoutFilter` | `pyfly.web.adapters.starlette.filters.*` | Form login + generic logout | +| `HttpBasicAuthenticationFilter` | `pyfly.web.adapters.starlette.filters.http_basic_filter` | HTTP Basic auth (RFC 7617) | +| `X509AuthenticationFilter` / `SwitchUserFilter` | `pyfly.web.adapters.starlette.filters.*` | Client-cert auth + run-as impersonation | +| `DelegatingPasswordEncoder` | `pyfly.security.password` | `{id}`-prefixed multi-algorithm encoder (bcrypt/PBKDF2/scrypt/Argon2) | +| `PermissionEvaluator` | `pyfly.security.permission` | ACL-style `hasPermission` SPI | +| `SecurityHeadersFilter`| `pyfly.web.adapters.starlette.filters.security_headers_filter` | OWASP response headers | +| `AuthorizationServerEndpoints` | `pyfly.security.oauth2.endpoints` | OAuth2/OIDC HTTP routes (token, authorize, jwks, introspect, …) | +| `OpaqueTokenIntrospector` | `pyfly.security.oauth2.resource_server` | RFC 7662 opaque-token validation | All components are exported from the top-level `pyfly.security` package: ```python from pyfly.security import ( SecurityContext, - JWTService, - PasswordEncoder, - BcryptPasswordEncoder, - SecurityMiddleware, - secure, + HttpSecurity, + pre_authorize, post_authorize, pre_filter, post_filter, secure, + RoleHierarchy, set_role_hierarchy, get_role_hierarchy, + PermissionEvaluator, set_permission_evaluator, get_permission_evaluator, + JWTService, SecurityMiddleware, + # Password encoders + PasswordEncoder, BcryptPasswordEncoder, Pbkdf2PasswordEncoder, + ScryptPasswordEncoder, Argon2PasswordEncoder, + DelegatingPasswordEncoder, create_delegating_password_encoder, + # Authentication SPI + UserDetails, UserDetailsService, InMemoryUserDetailsService, + Authentication, AuthenticationProvider, DaoAuthenticationProvider, ProviderManager, + AuthenticationException, BadCredentialsException, DisabledException, ProviderNotFoundException, ) # CSRF utilities from pyfly.security.csrf import generate_csrf_token, validate_csrf_token -from pyfly.web.adapters.starlette.filters.csrf_filter import CsrfFilter -# OAuth2 +# OAuth2 / OIDC (see the OAuth2 guide) from pyfly.security.oauth2 import ( - JWKSTokenValidator, - ClientRegistration, - ClientRegistrationRepository, - InMemoryClientRegistrationRepository, - AuthorizationServer, - TokenStore, - InMemoryTokenStore, - google, - github, - keycloak, + JWKSTokenValidator, OpaqueTokenIntrospector, ClaimMappings, + ClientRegistration, InMemoryClientRegistrationRepository, + AuthorizationServer, AuthorizationServerEndpoints, + TokenStore, InMemoryTokenStore, OAuth2LoginHandler, + google, github, keycloak, ) ``` @@ -403,6 +416,90 @@ encoder = BcryptPasswordEncoder() isinstance(encoder, PasswordEncoder) # True ``` +### Delegating & Modern Encoders + +Beyond `BcryptPasswordEncoder`, the password module ships PBKDF2, scrypt, and Argon2 adapters plus a `DelegatingPasswordEncoder` that prefixes each stored hash with a `{id}` tag so the active algorithm can be migrated over time without invalidating existing credentials (Spring Security's `DelegatingPasswordEncoder` / `PasswordEncoderFactories`). + +#### DelegatingPasswordEncoder + +`DelegatingPasswordEncoder` wraps a map of `{id -> PasswordEncoder}` and a default `encoding_id`. `hash()` produces `{}` using the default encoder; `verify()` reads the `{id}` prefix and dispatches to the matching encoder. A stored value whose prefix is unknown or missing never matches. `upgrade_encoding()` reports whether a stored hash should be re-hashed with the current default — the hook for transparent on-login migration. + +```python +from pyfly.security import ( + DelegatingPasswordEncoder, + BcryptPasswordEncoder, + Pbkdf2PasswordEncoder, +) + +encoder = DelegatingPasswordEncoder( + { + "bcrypt": BcryptPasswordEncoder(rounds=12), + "pbkdf2": Pbkdf2PasswordEncoder(), + }, + encoding_id="bcrypt", +) + +stored = encoder.hash("s3cret") # "{bcrypt}$2b$12$..." +encoder.verify("s3cret", stored) # True + +# A legacy PBKDF2 hash still verifies, and is flagged for upgrade: +legacy = "{pbkdf2}sha256$600000$$" +encoder.verify("s3cret", legacy) # True (dispatched to the pbkdf2 encoder) +encoder.upgrade_encoding(legacy) # True -> re-hash with the default (bcrypt) +encoder.upgrade_encoding(stored) # False -> already the default encoding +``` + +The constructor raises `ValueError` if `encoding_id` is not present in the encoders map. + +#### create_delegating_password_encoder + +`create_delegating_password_encoder()` builds a ready-made delegating encoder with bcrypt as the default id, while `{pbkdf2}`, `{scrypt}`, and `{argon2}` hashes remain recognised for verification and migration (Spring's `PasswordEncoderFactories.createDelegatingPasswordEncoder()`): + +```python +from pyfly.security import create_delegating_password_encoder + +encoder = create_delegating_password_encoder(bcrypt_rounds=12) +encoder.hash("s3cret") # "{bcrypt}$2b$12$..." +``` + +#### Argon2 / PBKDF2 / scrypt adapters + +Each modern adapter implements the `PasswordEncoder` protocol and produces a self-describing hash string, so its parameters travel with the value. + +| Encoder | Backing | Stored format | Defaults | +|---|---|---|---| +| `Argon2PasswordEncoder` | Argon2id (`argon2-cffi`) | argon2-cffi PHC string | `time_cost=3`, `memory_cost=65536`, `parallelism=4` | +| `Pbkdf2PasswordEncoder` | stdlib `hashlib.pbkdf2_hmac` | `$$$` | `iterations=600_000`, `algorithm="sha256"`, `salt_bytes=16` | +| `ScryptPasswordEncoder` | stdlib `hashlib.scrypt` | `$$

$$` | `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