Skip to content

Commit 4e3b540

Browse files
olivermeyerclaude
andauthored
feat(auth): consolidate auth config in AuthSettings (#57)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 6408c88 commit 4e3b540

8 files changed

Lines changed: 185 additions & 63 deletions

File tree

ATTRIBUTIONS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -360,7 +360,7 @@ SOFTWARE.
360360

361361
```
362362

363-
## aignostics-foundry-core (0.12.1) - MIT License
363+
## aignostics-foundry-core (0.13.0) - MIT License
364364

365365
🏭 Foundational infrastructure for Foundry components.
366366

README.md

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -210,13 +210,20 @@ ctx = make_context(database=DatabaseSettings(_env_prefix="TEST_DB_", url="sqlite
210210

211211
#### Authentication (`{PREFIX}AUTH_`)
212212

213-
Settings class: `AuthSettings`. Both fields are required — no defaults. Only needed when using
213+
Settings class: `AuthSettings`. All fields are optional with defaults unless `enabled=True`, which
214+
activates several cross-field requirements. Only needed when using
214215
`aignostics_foundry_core.api.auth` dependencies.
215216

216-
| Variable | Required | Description |
217-
|---|---|---|
218-
| `{PREFIX}AUTH_INTERNAL_ORG_ID` | yes | Auth0 organization ID identifying the internal org (used by `require_internal`). |
219-
| `{PREFIX}AUTH_AUTH0_ROLE_CLAIM` | yes | JWT claim name containing the user's role (e.g. `https://myapp.example.com/roles`). |
217+
| Variable | Required | Default | Description |
218+
|---|---|---|---|
219+
| `{PREFIX}AUTH_ENABLED` | no | `false` | Enable Auth0 authentication. When `true`, several other fields become required. |
220+
| `{PREFIX}AUTH_SESSION_SECRET` | when enabled | `""` | Secret to sign session cookies. Required when `AUTH_ENABLED=true`. |
221+
| `{PREFIX}AUTH_SESSION_EXPIRATION` | no | `86400` | Session cookie expiration in seconds (range: 61–31536000). |
222+
| `{PREFIX}AUTH_DOMAIN` | when enabled | `""` | Auth0 domain (e.g. `myapp.eu.auth0.com`). Required when `AUTH_ENABLED=true`. |
223+
| `{PREFIX}AUTH_CLIENT_ID` | when enabled | `""` | Auth0 client ID (max 32 chars). Required when `AUTH_ENABLED=true`. |
224+
| `{PREFIX}AUTH_CLIENT_SECRET` | when enabled | `""` | Auth0 client secret (64 chars). Required when `AUTH_ENABLED=true`. |
225+
| `{PREFIX}AUTH_INTERNAL_ORG_ID` | when enabled | `""` | Auth0 organization ID identifying the internal org (used by `require_internal`). Required when `AUTH_ENABLED=true`. |
226+
| `{PREFIX}AUTH_ROLE_CLAIM` | when enabled | `""` | JWT claim name containing the user's role (e.g. `https://myapp.example.com/roles`). Required when `AUTH_ENABLED=true`. |
220227

221228
#### Console
222229

src/aignostics_foundry_core/api/auth.py

Lines changed: 66 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
- Authentication dependencies (require_authenticated, require_admin, etc.)
66
- get_user: Get authenticated user from session
77
- get_auth_client: Get Auth0 client from app state
8-
- AuthSettings: Auth settings whose env prefix is derived from the active FoundryContext
8+
- AuthSettings: Full auth configuration (enabled, session, domain, credentials, org, role claim)
99
"""
1010

1111
import time
@@ -15,6 +15,7 @@
1515
from fastapi import Request, Security
1616
from fastapi.security import APIKeyCookie
1717
from loguru import logger
18+
from pydantic import Field, PlainSerializer, SecretStr, StringConstraints, model_validator
1819
from pydantic_settings import SettingsConfigDict
1920

2021
from aignostics_foundry_core.foundry import get_context
@@ -28,6 +29,7 @@
2829
AUTH0_COOKIE_SCHEME_DESCRIPTION = "Auth0 session cookie authentication scheme."
2930
AUTH0_ROLE_ADMIN = "admin"
3031
USER_NOT_AUTHENTICATED = "User is not authenticated"
32+
AUTH_SESSION_EXPIRATION_DEFAULT = 60 * 60 * 24 # 1 day in seconds
3133

3234

3335
class AuthSettings(OpaqueSettings):
@@ -37,20 +39,73 @@ class AuthSettings(OpaqueSettings):
3739
``FoundryContext.env_file``, both resolved at instantiation time via
3840
:func:`aignostics_foundry_core.foundry.get_context`.
3941
40-
Both ``internal_org_id`` and ``auth0_role_claim`` are required — they must be
41-
provided via environment variables or ``.env`` files (no defaults).
42+
Fields:
43+
enabled: Enable Auth0 authentication (AUTH_ENABLED).
44+
session_secret: Secret used to sign session cookies (AUTH_SESSION_SECRET).
45+
session_expiration: Session cookie expiration in seconds (AUTH_SESSION_EXPIRATION).
46+
domain: Auth0 domain (AUTH_DOMAIN).
47+
client_id: Auth0 client ID (AUTH_CLIENT_ID).
48+
client_secret: Auth0 client secret (AUTH_CLIENT_SECRET).
49+
internal_org_id: Auth0 org ID for the internal organisation (AUTH_INTERNAL_ORG_ID).
50+
role_claim: JWT claim name containing the user's role (AUTH_ROLE_CLAIM).
51+
52+
Cross-field rules (validated after field assignment):
53+
- enabled=True requires session_secret not None, client_secret not None,
54+
non-empty domain, client_id, internal_org_id, and role_claim
4255
"""
4356

4457
model_config = SettingsConfigDict(extra="ignore")
4558

46-
internal_org_id: str
47-
auth0_role_claim: str
59+
enabled: bool = Field(default=False)
60+
session_secret: Annotated[
61+
SecretStr | None,
62+
PlainSerializer(func=OpaqueSettings.serialize_sensitive_info, return_type=str, when_used="always"),
63+
] = Field(default=None)
64+
session_expiration: int = Field(default=AUTH_SESSION_EXPIRATION_DEFAULT, gt=60, le=31536000)
65+
domain: Annotated[str, StringConstraints(max_length=255)] = Field(default="")
66+
client_id: Annotated[str, StringConstraints(max_length=32)] = Field(default="")
67+
client_secret: Annotated[
68+
SecretStr | None,
69+
PlainSerializer(func=OpaqueSettings.serialize_sensitive_info, return_type=str, when_used="always"),
70+
] = Field(default=None, min_length=64, max_length=64)
71+
internal_org_id: str = ""
72+
role_claim: str = ""
4873

4974
def __init__(self, **kwargs: Any) -> None: # noqa: ANN401
5075
"""Initialise settings, deriving env_prefix and env files from the active FoundryContext."""
5176
ctx = get_context()
5277
super().__init__(_env_prefix=f"{ctx.env_prefix}AUTH_", _env_file=ctx.env_file, **kwargs) # pyright: ignore[reportCallIssue]
5378

79+
@model_validator(mode="after")
80+
def validate_auth_dependencies(self) -> "AuthSettings":
81+
"""Validate cross-field auth dependencies.
82+
83+
Returns:
84+
AuthSettings: The validated settings instance.
85+
86+
Raises:
87+
ValueError: If any cross-field dependency is violated.
88+
"""
89+
if self.enabled and self.session_secret is None:
90+
msg = "AUTH_SESSION_SECRET must not be None when AUTH_ENABLED is True"
91+
raise ValueError(msg)
92+
if self.enabled and self.client_secret is None:
93+
msg = "AUTH_CLIENT_SECRET must not be None when AUTH_ENABLED is True"
94+
raise ValueError(msg)
95+
if self.enabled and not self.domain:
96+
msg = "AUTH_DOMAIN must not be empty when AUTH_ENABLED is True"
97+
raise ValueError(msg)
98+
if self.enabled and not self.client_id:
99+
msg = "AUTH_CLIENT_ID must not be empty when AUTH_ENABLED is True"
100+
raise ValueError(msg)
101+
if self.enabled and not self.internal_org_id:
102+
msg = "AUTH_INTERNAL_ORG_ID must not be empty when AUTH_ENABLED is True"
103+
raise ValueError(msg)
104+
if self.enabled and not self.role_claim:
105+
msg = "AUTH_ROLE_CLAIM must not be empty when AUTH_ENABLED is True"
106+
raise ValueError(msg)
107+
return self
108+
54109

55110
class UnauthenticatedError(Exception):
56111
"""Raised when user is not authenticated."""
@@ -104,7 +159,7 @@ def get_auth_client(request: Request) -> AuthClient:
104159
name=AUTH0_SESSION_COOKIE_NAME,
105160
scheme_name="Auth0AdminCookie",
106161
description="Auth0 session cookie authentication with admin role requirement. "
107-
f"User must have '{AUTH0_ROLE_ADMIN}' role in their configured auth0_role_claim.",
162+
f"User must have '{AUTH0_ROLE_ADMIN}' role in their configured role_claim.",
108163
auto_error=False,
109164
) # Security scheme specifically for admin endpoints
110165

@@ -138,7 +193,7 @@ async def _require_authenticated_impl(
138193
request: The incoming request.
139194
_cookie: The session cookie.
140195
role: Optional role required (e.g., "admin"). If specified, user must have
141-
this role in their configured auth0_role_claim.
196+
this role in their configured role_claim.
142197
143198
Raises:
144199
UnauthenticatedError: If the session is not valid or missing.
@@ -154,7 +209,7 @@ async def _require_authenticated_impl(
154209

155210
# Check role if specified
156211
if role is not None:
157-
user_role = user.get(auth_settings.auth0_role_claim)
212+
user_role = user.get(auth_settings.role_claim)
158213
if user_role != role:
159214
msg = f"User role '{user_role}' does not match required role '{role}'"
160215
logger.warning(msg)
@@ -237,7 +292,7 @@ async def require_internal_admin(
237292
238293
Checks if the authenticated user is both:
239294
1. A member of the configured internal organization (FOUNDRY_AUTH_INTERNAL_ORG_ID)
240-
2. Has the admin role in their configured auth0_role_claim
295+
2. Has the admin role in their configured role_claim
241296
242297
Args:
243298
request: The incoming request.
@@ -263,7 +318,7 @@ async def require_internal_admin(
263318
raise ForbiddenError(msg)
264319

265320
# Check admin role
266-
user_role = user.get(auth_settings.auth0_role_claim)
321+
user_role = user.get(auth_settings.role_claim)
267322
if user_role != AUTH0_ROLE_ADMIN:
268323
msg = f"User role '{user_role}' does not match required role '{AUTH0_ROLE_ADMIN}'"
269324
logger.warning(msg)
@@ -315,7 +370,7 @@ async def me(user: Annotated[dict[str, Any], Depends(get_user)]):
315370
return None
316371
user: dict[str, Any] = raw_user # pyright: ignore[reportUnknownVariableType]
317372

318-
set_sentry_user(user, role_claim=auth_settings.auth0_role_claim)
373+
set_sentry_user(user, role_claim=auth_settings.role_claim)
319374

320375
# Check if expired
321376
exp = user.get("exp")

src/aignostics_foundry_core/gui/auth.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ async def get_gui_user(request: Request) -> dict[str, Any] | None:
170170
return None
171171
user: dict[str, Any] = raw_user # pyright: ignore[reportUnknownVariableType]
172172

173-
set_sentry_user(user, role_claim=auth_settings.auth0_role_claim) # pyright: ignore[reportUnknownArgumentType]
173+
set_sentry_user(user, role_claim=auth_settings.role_claim) # pyright: ignore[reportUnknownArgumentType]
174174

175175
exp = user.get("exp")
176176
if not exp:
@@ -319,7 +319,7 @@ async def wrapper(request: Request) -> None:
319319
return
320320

321321
auth_settings = load_settings(AuthSettings)
322-
role = user.get(auth_settings.auth0_role_claim)
322+
role = user.get(auth_settings.role_claim)
323323
if role != AUTH0_ROLE_ADMIN:
324324
with _frame_context(frame_func, resolved_title, user):
325325
ui.label(f"{MSG_403_FORBIDDEN} - Admin access required").classes(CLASS_FORBIDDEN_ERROR)
@@ -402,7 +402,7 @@ async def wrapper(request: Request) -> None:
402402

403403
auth_settings = load_settings(AuthSettings)
404404
org_id = user.get("org_id")
405-
role = user.get(auth_settings.auth0_role_claim)
405+
role = user.get(auth_settings.role_claim)
406406

407407
if org_id != auth_settings.internal_org_id or role != AUTH0_ROLE_ADMIN:
408408
with _frame_context(frame_func, resolved_title, user):

tests/aignostics_foundry_core/api/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@
33
from tests.conftest import TEST_PROJECT_PREFIX
44

55
INTERNAL_ORG_ID_VAR_NAME = f"{TEST_PROJECT_PREFIX}AUTH_INTERNAL_ORG_ID"
6-
AUTH0_ROLE_CLAIM_VAR_NAME = f"{TEST_PROJECT_PREFIX}AUTH_AUTH0_ROLE_CLAIM"
6+
ROLE_CLAIM_VAR_NAME = f"{TEST_PROJECT_PREFIX}AUTH_ROLE_CLAIM"

0 commit comments

Comments
 (0)