Skip to content

Commit 9f6a3be

Browse files
olivermeyerclaude
andcommitted
feat(auth): consolidate auth config in AuthSettings
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 6408c88 commit 9f6a3be

8 files changed

Lines changed: 204 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: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -210,13 +210,21 @@ 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_ENABLED` | when enabled | `false` | Enable session cookies. Required when `AUTH_ENABLED=true`. |
221+
| `{PREFIX}AUTH_SESSION_SECRET` | when session enabled | `""` | Secret to sign session cookies. Required when `AUTH_SESSION_ENABLED=true`. |
222+
| `{PREFIX}AUTH_SESSION_EXPIRATION` | no | `86400` | Session cookie expiration in seconds (range: 61–31536000). |
223+
| `{PREFIX}AUTH_DOMAIN` | when enabled | `""` | Auth0 domain (e.g. `myapp.eu.auth0.com`). Required when `AUTH_ENABLED=true`. |
224+
| `{PREFIX}AUTH_CLIENT_ID` | when enabled | `""` | Auth0 client ID (max 32 chars). Required when `AUTH_ENABLED=true`. |
225+
| `{PREFIX}AUTH_CLIENT_SECRET` | when enabled | `""` | Auth0 client secret (64 chars). Required when `AUTH_ENABLED=true`. |
226+
| `{PREFIX}AUTH_INTERNAL_ORG_ID` | when enabled | `""` | Auth0 organization ID identifying the internal org (used by `require_internal`). Required when `AUTH_ENABLED=true`. |
227+
| `{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`. |
220228

221229
#### Console
222230

src/aignostics_foundry_core/api/auth.py

Lines changed: 73 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,80 @@ 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_enabled: Enable session cookies (AUTH_SESSION_ENABLED).
45+
session_secret: Secret used to sign session cookies (AUTH_SESSION_SECRET).
46+
session_expiration: Session cookie expiration in seconds (AUTH_SESSION_EXPIRATION).
47+
domain: Auth0 domain (AUTH_DOMAIN).
48+
client_id: Auth0 client ID (AUTH_CLIENT_ID).
49+
client_secret: Auth0 client secret (AUTH_CLIENT_SECRET).
50+
internal_org_id: Auth0 org ID for the internal organisation (AUTH_INTERNAL_ORG_ID).
51+
role_claim: JWT claim name containing the user's role (AUTH_ROLE_CLAIM).
52+
53+
Cross-field rules (validated after field assignment):
54+
- enabled=True requires session_enabled=True
55+
- session_enabled=True requires session_secret not None
56+
- enabled=True requires client_secret not None, non-empty domain, client_id,
57+
internal_org_id, and role_claim
4258
"""
4359

4460
model_config = SettingsConfigDict(extra="ignore")
4561

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

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

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

55117
class UnauthenticatedError(Exception):
56118
"""Raised when user is not authenticated."""
@@ -104,7 +166,7 @@ def get_auth_client(request: Request) -> AuthClient:
104166
name=AUTH0_SESSION_COOKIE_NAME,
105167
scheme_name="Auth0AdminCookie",
106168
description="Auth0 session cookie authentication with admin role requirement. "
107-
f"User must have '{AUTH0_ROLE_ADMIN}' role in their configured auth0_role_claim.",
169+
f"User must have '{AUTH0_ROLE_ADMIN}' role in their configured role_claim.",
108170
auto_error=False,
109171
) # Security scheme specifically for admin endpoints
110172

@@ -138,7 +200,7 @@ async def _require_authenticated_impl(
138200
request: The incoming request.
139201
_cookie: The session cookie.
140202
role: Optional role required (e.g., "admin"). If specified, user must have
141-
this role in their configured auth0_role_claim.
203+
this role in their configured role_claim.
142204
143205
Raises:
144206
UnauthenticatedError: If the session is not valid or missing.
@@ -154,7 +216,7 @@ async def _require_authenticated_impl(
154216

155217
# Check role if specified
156218
if role is not None:
157-
user_role = user.get(auth_settings.auth0_role_claim)
219+
user_role = user.get(auth_settings.role_claim)
158220
if user_role != role:
159221
msg = f"User role '{user_role}' does not match required role '{role}'"
160222
logger.warning(msg)
@@ -237,7 +299,7 @@ async def require_internal_admin(
237299
238300
Checks if the authenticated user is both:
239301
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
302+
2. Has the admin role in their configured role_claim
241303
242304
Args:
243305
request: The incoming request.
@@ -263,7 +325,7 @@ async def require_internal_admin(
263325
raise ForbiddenError(msg)
264326

265327
# Check admin role
266-
user_role = user.get(auth_settings.auth0_role_claim)
328+
user_role = user.get(auth_settings.role_claim)
267329
if user_role != AUTH0_ROLE_ADMIN:
268330
msg = f"User role '{user_role}' does not match required role '{AUTH0_ROLE_ADMIN}'"
269331
logger.warning(msg)
@@ -315,7 +377,7 @@ async def me(user: Annotated[dict[str, Any], Depends(get_user)]):
315377
return None
316378
user: dict[str, Any] = raw_user # pyright: ignore[reportUnknownVariableType]
317379

318-
set_sentry_user(user, role_claim=auth_settings.auth0_role_claim)
380+
set_sentry_user(user, role_claim=auth_settings.role_claim)
319381

320382
# Check if expired
321383
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)