Skip to content
6 changes: 6 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down
25 changes: 25 additions & 0 deletions src/pyfly/idp/adapters/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
)
4 changes: 4 additions & 0 deletions src/pyfly/idp/adapters/aws_cognito.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)).
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions src/pyfly/idp/adapters/azure_ad.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions src/pyfly/idp/adapters/keycloak.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand Down
11 changes: 11 additions & 0 deletions src/pyfly/idp/auto_configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
73 changes: 69 additions & 4 deletions src/pyfly/security/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]

Expand All @@ -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
Loading
Loading