Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,51 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

---

## v26.06.107 (2026-06-16)

### Added

- **OAuth2 resource server: config-driven, multi-IdP, Spring-parity.** The
bearer-token resource server now works out of the box with **Keycloak**,
**Microsoft Entra ID** (v1.0 + v2.0) and **AWS Cognito** via configuration
alone (no subclassing), and reaches Spring-Security parity:
- **`issuer-uri` OIDC discovery** — derive the JWKS endpoint + issuer from
`<issuer-uri>/.well-known/openid-configuration` (alternative to `jwks-uri`).
- **Config-driven claim mapping** (`pyfly.security.oauth2.resource-server.*`):
`principal-claim-names`, `authorities-claim-names`, `scope-claim-names`,
`attribute-claims`, `authority-prefix`. Claim names accept **dotted paths**
with a `*` wildcard and are colon-safe, so authorities resolve from
`realm_access.roles`, `resource_access.*.roles` (Keycloak), `roles` + `groups`
(Entra), and `cognito:groups` (Cognito) with zero code.
- **`audiences`** (a list; `aud` must match any) and **`validate-audience`**
(disable for Cognito *access* tokens, which carry no `aud`).
- Configurable **`algorithms`**, **`clock-skew-seconds`**, **`jwks-timeout-seconds`**,
**`jwks-cache-seconds`**.
- New typed **`ResourceServerProperties`** (`@config_properties`) and
**`ClaimMappings`**.

### Fixed

- **OAuth2 resource server — clock-skew leeway.** JWT validation now allows 60s
of clock skew by default (configurable). Previously a token whose `iat`/`nbf`
was a few seconds ahead of the server clock — routine with real IdPs — was
rejected as "not yet valid", causing intermittent 401s.
- **OAuth2 resource server — event-loop stall.** The bearer filter now runs JWKS
validation (which does blocking network I/O on a cache miss) in a worker thread
(`anyio.to_thread`) instead of inline on the event loop.
- **OAuth2 resource server — multi-IdP claim coverage.** Token-to-`SecurityContext`
mapping previously read only `realm_access.roles` and `scope`/`permissions`,
silently dropping Keycloak `resource_access` client roles, Entra `groups` /
`scp`, Cognito `cognito:groups`, and any token `attributes`. All are now mapped
(configurably).
- **OAuth2 resource server — case-insensitive `Bearer` scheme** (RFC 7235): a
`bearer …` / `BEARER …` Authorization header is now accepted.
- **OAuth2 resource server — opt-in strict rejection.** New
`authenticate-error-mode: "401"` rejects a *present-but-invalid* token at the
filter with `401` + `WWW-Authenticate: Bearer error="invalid_token"` (RFC
6750). Default remains `"anonymous"` (the gate decides) — no behavioural change
unless opted in.

## v26.06.106 (2026-06-16)

### Fixed
Expand Down
79 changes: 54 additions & 25 deletions docs/modules/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -954,50 +954,79 @@ from pyfly.security.oauth2 import (

### OAuth2 Resource Server (JWKS)

The `JWKSTokenValidator` validates RS256-signed JWTs using a remote JWKS (JSON Web Key Set) endpoint. This is used when your application acts as an **OAuth2 Resource Server** -- it receives tokens issued by an external authorization server and validates them.
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.

#### Enable via configuration (recommended)

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.

```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/<tenant>/v2.0" # discovers jwks-uri + issuer
# jwks-uri: "https://login.microsoftonline.com/<tenant>/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://<host>/realms/<r>` | `realm_access.roles`, `resource_access.*.roles` | `scope` | client / `account` |
| **Entra ID v2.0** | `https://login.microsoftonline.com/<tid>/v2.0` | `roles`, `groups` | `scp` | `api://…` or client GUID |
| **Cognito (access)** | `https://cognito-idp.<region>.amazonaws.com/<pool>` | `cognito:groups` | `scope` | **none** → set `validate-audience: false` |

#### Programmatic use

```python
from pyfly.security.oauth2 import JWKSTokenValidator
from pyfly.security.oauth2 import JWKSTokenValidator, ClaimMappings

validator = JWKSTokenValidator(
jwks_uri="https://auth.example.com/.well-known/jwks.json",
issuer="https://auth.example.com",
audience="my-api",
audiences=["my-api"],
leeway=60,
claim_mappings=ClaimMappings(attribute_claims=("tid",)),
)
ctx = validator.to_security_context(token)
# SecurityContext(user_id=..., roles=[...], permissions=[...], attributes={...})
```

**Constructor parameters:**

| Parameter | Type | Default | Description |
|---|---|---|---|
| `jwks_uri` | `str` | required | URL of the JWKS endpoint |
| `issuer` | `str \| None` | `None` | Expected `iss` claim (validates if set) |
| `audience` | `str \| None` | `None` | Expected `aud` claim (validates if set) |
| `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 |

**Validating tokens:**

```python
# Validate and get raw payload
payload = validator.validate(token)
# {"sub": "user-123", "roles": ["ADMIN"], "scope": "read write", ...}

# Validate and build SecurityContext directly
ctx = validator.to_security_context(token)
# SecurityContext(user_id="user-123", roles=["ADMIN"], permissions=["read", "write"])
```
**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`.

**Claim mapping for `to_security_context()`:**
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.

| JWT Claim | SecurityContext Field | Notes |
|---|---|---|
| `sub` | `user_id` | Standard subject claim |
| `roles` | `roles` | Flat roles array |
| `realm_access.roles` | `roles` | Keycloak-style nested roles (fallback) |
| `permissions` | `permissions` | Flat permissions array |
| `scope` | `permissions` | Space-separated scopes (fallback, split on spaces) |
**OIDC discovery:** set `issuer-uri` (instead of `jwks-uri`) and the framework fetches `<issuer-uri>/.well-known/openid-configuration` to learn the `jwks_uri` + `issuer`.

**Source:** `src/pyfly/security/oauth2/resource_server.py`
**Source:** `src/pyfly/security/oauth2/resource_server.py`, `src/pyfly/security/oauth2/properties.py`

### OAuth2 Client Registration

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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.106"
version = "26.6.107"
description = "The official Python implementation of the Firefly Framework — DI, CQRS, EDA, hexagonal architecture, and more."
readme = "README.md"
license = "Apache-2.0"
Expand Down
2 changes: 1 addition & 1 deletion src/pyfly/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,4 @@
# limitations under the License.
"""PyFly — Enterprise Python Framework."""

__version__ = "26.06.106"
__version__ = "26.06.107"
61 changes: 49 additions & 12 deletions src/pyfly/security/auto_configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,20 @@
BcryptPasswordEncoder = object # type: ignore[misc,assignment]

try:
from pyfly.security.oauth2.resource_server import JWKSTokenValidator
from pyfly.security.oauth2.resource_server import (
ClaimMappings,
JWKSTokenValidator,
discover_oidc,
)
except ImportError:
JWKSTokenValidator = object # type: ignore[misc,assignment]
ClaimMappings = object # type: ignore[misc,assignment]
discover_oidc = None # type: ignore[assignment]

try:
from pyfly.security.oauth2.properties import ResourceServerProperties
except ImportError:
ResourceServerProperties = object # type: ignore[misc,assignment]

try:
from pyfly.security.oauth2.authorization_server import AuthorizationServer, InMemoryTokenStore
Expand Down Expand Up @@ -134,23 +145,47 @@ def password_encoder(self, config: Config) -> BcryptPasswordEncoder:
@conditional_on_property("pyfly.security.oauth2.resource-server.enabled", having_value="true")
@conditional_on_class("jwt")
class OAuth2ResourceServerAutoConfiguration:
"""Auto-configures a JWKSTokenValidator when a JWKS URI is provided.

Activated when ``pyfly.security.oauth2.resource-server.enabled=true``
and ``pyjwt`` is installed. Reads the JWKS endpoint, issuer, and
audience from configuration properties.
"""Auto-configures a multi-IdP :class:`JWKSTokenValidator`.

Activated when ``pyfly.security.oauth2.resource-server.enabled=true`` and
``pyjwt`` is installed. Binds :class:`ResourceServerProperties` and works out
of the box with Keycloak, Microsoft Entra ID (v1.0 + v2.0) and AWS Cognito —
deriving the JWKS endpoint via OIDC discovery from ``issuer-uri`` when no
explicit ``jwks-uri`` is given, and mapping authorities / scopes / principal
from a configurable set of claim paths.
"""

@bean
@conditional_on_missing_bean(JWKSTokenValidator)
def jwks_token_validator(self, config: Config) -> JWKSTokenValidator:
jwks_uri = str(config.get("pyfly.security.oauth2.resource-server.jwks-uri", ""))
issuer = config.get("pyfly.security.oauth2.resource-server.issuer")
audience = config.get("pyfly.security.oauth2.resource-server.audience")
props = config.bind(ResourceServerProperties)

jwks_uri = props.jwks_uri
issuer = props.issuer or None
# OIDC discovery (Spring's ``issuer-uri``): derive the JWKS endpoint and
# the authoritative issuer from the provider's discovery document.
if not jwks_uri and props.issuer_uri:
jwks_uri, discovered_issuer = discover_oidc(props.issuer_uri, timeout=float(props.jwks_timeout_seconds))
issuer = issuer or discovered_issuer

mappings = ClaimMappings(
principal_claims=tuple(props.principal_claim_list()),
authority_claims=tuple(props.authorities_claim_list()),
scope_claims=tuple(props.scope_claim_list()),
authority_prefix=props.authority_prefix,
attribute_claims=tuple(props.attribute_claim_list()),
)

return JWKSTokenValidator(
jwks_uri=jwks_uri,
issuer=str(issuer) if issuer is not None else None,
audience=str(audience) if audience is not None else None,
issuer=issuer,
audiences=props.audience_list(),
algorithms=props.algorithm_list(),
leeway=props.clock_skew_seconds,
validate_audience=props.validate_audience,
claim_mappings=mappings,
jwks_timeout=float(props.jwks_timeout_seconds),
jwks_cache_seconds=props.jwks_cache_seconds,
)

@bean
Expand All @@ -160,9 +195,11 @@ def oauth2_resource_server_filter(self, token_validator: JWKSTokenValidator, con
# rescan adds it to the chain whenever the resource server is on (#41).
from pyfly.web.adapters.starlette.filters.oauth2_resource_filter import OAuth2ResourceServerFilter

props = config.bind(ResourceServerProperties)
return OAuth2ResourceServerFilter(
token_validator=token_validator,
exclude_patterns=_exclude_patterns(config, "pyfly.security.oauth2.resource-server.exclude-patterns"),
exclude_patterns=props.exclude_pattern_list(),
error_mode=props.authenticate_error_mode,
)


Expand Down
6 changes: 5 additions & 1 deletion src/pyfly/security/oauth2/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,19 +27,23 @@
keycloak,
)
from pyfly.security.oauth2.login import OAuth2LoginHandler
from pyfly.security.oauth2.resource_server import JWKSTokenValidator
from pyfly.security.oauth2.properties import ResourceServerProperties
from pyfly.security.oauth2.resource_server import ClaimMappings, JWKSTokenValidator, discover_oidc
from pyfly.security.oauth2.session_security_filter import OAuth2SessionSecurityFilter

__all__ = [
"AuthorizationServer",
"ClaimMappings",
"ClientRegistration",
"ClientRegistrationRepository",
"InMemoryClientRegistrationRepository",
"InMemoryTokenStore",
"JWKSTokenValidator",
"OAuth2LoginHandler",
"OAuth2SessionSecurityFilter",
"ResourceServerProperties",
"TokenStore",
"discover_oidc",
"github",
"google",
"keycloak",
Expand Down
3 changes: 2 additions & 1 deletion src/pyfly/security/oauth2/login.py
Original file line number Diff line number Diff line change
Expand Up @@ -301,7 +301,8 @@ def _validate_id_token(self, registration: Any, id_token: str, nonce: str | None
validator = JWKSTokenValidator(
jwks_uri=registration.jwks_uri,
issuer=getattr(registration, "issuer_uri", "") or None,
audience=registration.client_id,
# An OIDC id_token's audience is the client_id.
audiences=[registration.client_id],
)
try:
claims = validator.validate(id_token)
Expand Down
Loading
Loading