feat(oidc): Azure Entra ID support — configurable email claim & verification + Remember Me persistent login (#1126)

feat(oidc): add Azure Entra ID support with configurable email claim resolution

Adds two new OIDC provider fields: email_claim and require_email_verified.
This commit is contained in:
Sn0rrii
2026-04-25 13:32:42 +02:00
committed by GitHub
parent 4304a42542
commit fdaec47378
22 changed files with 2288 additions and 73 deletions
+136 -18
View File
@@ -58,6 +58,7 @@ from backend.app.models.user import User
from backend.app.models.user_otp_code import UserOTPCode
from backend.app.models.user_totp import UserTOTP
from backend.app.schemas.auth import (
AUTO_LINK_REQUIREMENTS_ERROR,
AdminDisable2FARequest,
BackupCodesResponse,
EmailOTPDisableRequest,
@@ -373,6 +374,126 @@ def _assert_totp_not_replayed(totp_obj: pyotp.TOTP, totp_record: UserTOTP, code:
totp_record.accept_counter(accepted_counter)
# ---------------------------------------------------------------------------
# OIDC helpers
# ---------------------------------------------------------------------------
_EMAIL_SHAPE_RE = re.compile(r"[^\s@]+@[^\s@]+\.[^\s@]+")
def _is_valid_email_shaped(value: str | None) -> bool:
# SEC-2: shape check for non-standard claims (upn, preferred_username).
# Requires local@domain.tld — rejects "@", "x@", "@domain", "x@nodot".
if not value or len(value) > 255:
return False
return _EMAIL_SHAPE_RE.fullmatch(value) is not None
def _enforce_auto_link_safety(provider: OIDCProvider) -> None:
"""Raise HTTP 422 if auto_link_existing_accounts is on without safe email settings.
SEC-1/SEC-6: auto_link is only safe when require_email_verified=True AND
email_claim='email'. Called after ORM construction (create) and after the
setattr loop (update) so partial-update bypasses are also caught.
"""
if provider.auto_link_existing_accounts and (
not provider.require_email_verified or provider.email_claim != "email"
):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=AUTO_LINK_REQUIREMENTS_ERROR,
)
def _resolve_provider_email(provider: OIDCProvider, claims: dict, provider_sub: str) -> str | None:
"""Extract and normalise the email address from OIDC ID-token claims.
Implements three resolution paths (Fall A/B/C):
Fall C — custom email_claim (!= "email"): shape-check only, no email_verified gate.
Recommended for Azure Entra ID (preferred_username or upn).
Fall A — email_claim="email" + require_email_verified=True: strict, email_verified must be True.
Fall B — email_claim="email" + require_email_verified=False: permissive, explicit False drops email.
Returns a lowercase-stripped email string, or None when the claim is absent/invalid.
"""
provider_id = provider.id
raw_claim_value = claims.get(provider.email_claim)
if raw_claim_value is not None and not isinstance(raw_claim_value, str):
# TYPE-GUARD: non-string claim (e.g. list, int) would raise AttributeError on .lower().
logger.warning(
"OIDC provider %d: email_claim %r has unexpected type %s for sub=%r, ignoring",
provider_id,
provider.email_claim,
type(raw_claim_value).__name__,
provider_sub,
)
raw_claim_value = None
raw_email: str | None = raw_claim_value.lower().strip() if raw_claim_value else None
if provider.email_claim != "email":
# Fall C: custom claim (preferred_username, upn, …) — no email_verified check.
# SEC-2: _is_valid_email_shaped instead of bare '"@" in value'.
# Recommended for Azure Entra ID: set email_claim="preferred_username" or "upn".
if raw_email and _is_valid_email_shaped(raw_email):
return raw_email
if raw_email:
logger.warning(
"OIDC provider %d: email_claim %r value failed shape check for sub=%r, ignoring",
provider_id,
provider.email_claim,
provider_sub,
)
return None
email_verified = claims.get("email_verified")
if provider.require_email_verified:
# Fall A: standard C1-Guard — fail closed unless email_verified is True.
# SEC-2: apply shape check to standard email claim — providers may set
# email_verified=True on non-email values (e.g. numeric user IDs).
# SEC-3 normalisation applies; existing mixed-case provider_email records
# were normalised to lowercase by run_migrations at startup.
if raw_email and not _is_valid_email_shaped(raw_email):
logger.warning(
"OIDC provider %d: email claim failed shape check for sub=%r, ignoring",
provider_id,
provider_sub,
)
return None
if email_verified is True:
return raw_email
if raw_email:
logger.info(
"OIDC provider %d: ignoring email for sub=%r because email_verified=%r",
provider_id,
provider_sub,
email_verified,
)
return None
# Fall B: permissive — explicit False drops email, absent/None keeps it.
# Required for Azure Entra ID which never sends email_verified.
# SEC-2: apply shape check before the email_verified=False drop so malformed
# values are rejected regardless of the email_verified claim.
if raw_email and not _is_valid_email_shaped(raw_email):
logger.warning(
"OIDC provider %d: email claim failed shape check for sub=%r, ignoring",
provider_id,
provider_sub,
)
return None
if email_verified is False:
return None
if email_verified is not True:
# SEC-5: log only when the permissive path actually fires (ev absent/None),
# not on every successful login.
logger.info(
"OIDC provider %r (%d): accepting email for sub=%r without email_verified claim (permissive mode)",
provider.name,
provider.id,
provider_sub,
)
return raw_email
# ---------------------------------------------------------------------------
# Settings helpers (email 2FA flag)
# ---------------------------------------------------------------------------
@@ -1056,8 +1177,14 @@ async def create_oidc_provider(
scopes=body.scopes,
is_enabled=body.is_enabled,
auto_create_users=body.auto_create_users,
auto_link_existing_accounts=body.auto_link_existing_accounts,
email_claim=body.email_claim,
require_email_verified=body.require_email_verified,
icon_url=body.icon_url,
)
# SEC-1 + SEC-6: runtime guard mirrors the OIDCProviderCreate model_validator in schemas/auth.py.
# Catches any future path that bypasses Pydantic validation (direct ORM, scripts).
_enforce_auto_link_safety(provider)
db.add(provider)
await db.commit()
await db.refresh(provider)
@@ -1082,6 +1209,11 @@ async def update_oidc_provider(
value = value.rstrip("/")
setattr(provider, field, value)
# SEC-1 + SEC-6: Combined-State-Guard after setattr loop.
# Checks the final in-memory state (DB values + newly set values combined) to catch
# partial updates that each pass schema validation individually but are unsafe together.
_enforce_auto_link_safety(provider)
await db.commit()
await db.refresh(provider)
return OIDCProviderResponse.model_validate(provider)
@@ -1370,23 +1502,8 @@ async def oidc_callback(
if not provider_sub:
return RedirectResponse(url=f"{frontend_error_url}missing_sub_claim", status_code=302)
# C1: Only trust the email claim when the provider explicitly marks it verified.
# Treating absent email_verified as verified enables account-takeover: an attacker
# could register an unverified email with an IdP and auto-link to an existing account.
# Fail closed: require email_verified == True; absent/False both drop the email.
raw_email: str | None = claims.get("email")
email_verified = claims.get("email_verified")
if email_verified is not True:
if raw_email:
logger.info(
"OIDC provider %d: ignoring email for sub=%r because email_verified=%r",
provider_id,
provider_sub,
email_verified,
)
provider_email: str | None = None
else:
provider_email = raw_email
# SEC-3: resolve email via Fall A/B/C logic (see _resolve_provider_email).
provider_email = _resolve_provider_email(provider, claims, provider_sub)
# ── Step 4: Resolve / create user ────────────────────────────────────
try:
@@ -1549,7 +1666,8 @@ async def oidc_callback(
logger.error("Unexpected error in OIDC callback (%s): %s", type(exc).__name__, exc, exc_info=True)
try:
return RedirectResponse(url=f"{frontend_error_url}internal_error", status_code=302)
except Exception:
except Exception as redirect_exc:
logger.error("Failed to construct error redirect in OIDC callback: %s", redirect_exc, exc_info=True)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="OIDC callback failed")
+138 -18
View File
@@ -214,22 +214,66 @@ async def init_db():
async def _safe_execute(conn, sql):
"""Execute a migration statement, ignoring 'already exists' errors.
"""Execute a DDL migration statement, silently ignoring idempotency errors.
Uses a savepoint so that a failed statement doesn't poison the
surrounding transaction (required for PostgreSQL).
'already exists', 'duplicate column name' (SQLite ADD COLUMN), 'no such column'
(SQLite RENAME COLUMN), 'duplicate key', and the compound
'column … does not exist' (PostgreSQL RENAME COLUMN idempotency) are swallowed
so that re-running DDL migrations is safe. The compound check additionally
requires the SQL to be a RENAME COLUMN statement so that "does not exist" errors
from ADD COLUMN or CREATE INDEX (which would indicate schema corruption, not
idempotency) are never silently swallowed.
Any other error is logged and re-raised — callers must not assume silent
recovery, as a failure will abort the migration sequence and prevent
application startup.
Only use for DDL statements (ALTER TABLE, CREATE INDEX, etc.).
For DML backfills (UPDATE, DELETE) use conn.execute() directly inside
async with conn.begin_nested() so failures are never silently swallowed.
Uses a savepoint so that a failed statement doesn't poison the surrounding
transaction (required for PostgreSQL).
"""
from sqlalchemy import text
try:
async with conn.begin_nested():
await conn.execute(text(sql))
except (OperationalError, ProgrammingError):
pass
except (OperationalError, ProgrammingError) as exc:
msg = str(exc).lower()
# Only swallow "column … does not exist" for RENAME COLUMN — not for ADD COLUMN
# or CREATE INDEX where it would indicate schema corruption, not idempotency.
column_not_exists = "rename column" in sql.lower() and "column" in msg and "does not exist" in msg
if (
not any(k in msg for k in ("already exists", "duplicate key", "duplicate column name", "no such column"))
and not column_not_exists
):
logger.error("Migration statement failed: %s | SQL: %.200s", exc, sql)
raise
async def _migrate_normalize_printer_ids(conn) -> None:
from sqlalchemy import text
async with conn.begin_nested():
if is_sqlite():
await conn.execute(text("UPDATE api_keys SET printer_ids = NULL WHERE printer_ids = '[]'"))
else:
await conn.execute(text("UPDATE api_keys SET printer_ids = NULL WHERE printer_ids::text = '[]'"))
async def run_migrations(conn):
"""Add new columns to existing tables if they don't exist."""
"""Run all schema migrations and data backfills on startup.
Includes ALTER TABLE (add columns, rename columns, add constraints),
CREATE INDEX, CREATE TRIGGER, data UPDATE backfills, and table recreations
for complex SQLite schema changes that ALTER TABLE cannot handle.
DDL statements are wrapped in _safe_execute for idempotency.
DML backfills (UPDATE/DELETE) are executed directly via conn.execute()
inside begin_nested() so any failure is always fatal and never silently
swallowed.
"""
from sqlalchemy import text
# Migration: Add is_favorite column to print_archives
@@ -1403,7 +1447,9 @@ async def run_migrations(conn):
# Migration: Normalize empty printer_ids [] to NULL (global access) on API keys
# Previously both None and [] meant "all printers"; now [] means "no printers"
await _safe_execute(conn, "UPDATE api_keys SET printer_ids = NULL WHERE printer_ids = '[]'")
# PostgreSQL stores printer_ids as JSONB; comparing JSONB to a string literal fails
# with "operator does not exist: jsonb = unknown" — cast the literal to jsonb explicitly.
await _migrate_normalize_printer_ids(conn)
# Migration: Add auth_source column to users for LDAP support (#794)
await _safe_execute(conn, "ALTER TABLE users ADD COLUMN auth_source VARCHAR(20) DEFAULT 'local' NOT NULL")
@@ -1434,8 +1480,13 @@ async def run_migrations(conn):
)
await conn.execute(text(f"PRAGMA schema_version = {schema_version + 1}"))
await conn.execute(text("PRAGMA writable_schema = OFF"))
except (OperationalError, ProgrammingError):
pass
except (OperationalError, ProgrammingError) as exc:
logger.error(
"Failed to remove NOT NULL from users.password_hash via writable_schema — "
"OIDC/LDAP user creation will fail on this install: %s",
exc,
exc_info=True,
)
else:
await _safe_execute(conn, "ALTER TABLE users ALTER COLUMN password_hash DROP NOT NULL")
@@ -1489,7 +1540,67 @@ async def run_migrations(conn):
await _safe_execute(conn, "ALTER TABLE auth_ephemeral_tokens ADD COLUMN challenge_id VARCHAR(128)")
# Migration: Add auto_link_existing_accounts column to oidc_providers (M-4)
await _safe_execute(conn, "ALTER TABLE oidc_providers ADD COLUMN auto_link_existing_accounts BOOLEAN DEFAULT 1")
# Postgres rejects `DEFAULT 0` for BOOLEAN columns.
if is_sqlite():
await _safe_execute(conn, "ALTER TABLE oidc_providers ADD COLUMN auto_link_existing_accounts BOOLEAN DEFAULT 0")
else:
await _safe_execute(
conn, "ALTER TABLE oidc_providers ADD COLUMN auto_link_existing_accounts BOOLEAN DEFAULT false"
)
# Migration: Azure Entra ID support — configurable email claim and verification requirement
await _safe_execute(conn, "ALTER TABLE oidc_providers ADD COLUMN email_claim VARCHAR(64) DEFAULT 'email'")
# Postgres rejects `DEFAULT 1` for BOOLEAN columns.
if is_sqlite():
await _safe_execute(conn, "ALTER TABLE oidc_providers ADD COLUMN require_email_verified BOOLEAN DEFAULT 1")
else:
await _safe_execute(conn, "ALTER TABLE oidc_providers ADD COLUMN require_email_verified BOOLEAN DEFAULT true")
# SEC-1 backfill: reset auto_link on rows where the combined state is unsafe.
# Runs BEFORE the CHECK constraint below so existing installs that have
# auto_link=TRUE + unsafe email settings self-heal rather than failing when
# PostgreSQL validates ADD CONSTRAINT against existing rows ("check constraint
# is violated by some row"). On fresh installs the column defaults guarantee
# this UPDATE matches zero rows. TRUE/FALSE literals are accepted by both
# SQLite (≥ 3.23) and PostgreSQL — no dialect branch needed.
try:
async with conn.begin_nested():
await conn.execute(
text(
"UPDATE oidc_providers SET auto_link_existing_accounts = FALSE "
"WHERE auto_link_existing_accounts = TRUE "
"AND (require_email_verified = FALSE OR email_claim != 'email')"
)
)
except Exception as exc:
logger.error(
"SEC-1 safety backfill FAILED — auto_link_existing_accounts may remain enabled "
"on providers with unsafe email settings: %s",
exc,
exc_info=True,
)
raise
# SEC-1/SEC-6: Add DB-level CHECK constraint for existing PostgreSQL installs.
# SQLite does not support ALTER TABLE ADD CONSTRAINT — handled by __table_args__ at creation.
# Runs AFTER the backfill so legacy unsafe rows don't fail constraint validation.
if not is_sqlite():
try:
async with conn.begin_nested():
await conn.execute(
text(
"ALTER TABLE oidc_providers ADD CONSTRAINT ck_auto_link_requires_verified_email_claim "
"CHECK (auto_link_existing_accounts = FALSE OR (require_email_verified = TRUE AND email_claim = 'email'))"
)
)
except (OperationalError, ProgrammingError) as exc:
msg = str(exc).lower()
if "already exists" not in msg:
logger.error(
"Security constraint migration FAILED — auto_link safety constraint may not be enforced: %s",
exc,
exc_info=True,
)
raise
# Migration: Add password_changed_at to users (M-R7-B)
# Tracks the last time a user's password was changed/reset. JWTs whose iat
@@ -1505,10 +1616,8 @@ async def run_migrations(conn):
# tokens could never be invalidated via the freshness check. Setting it to
# created_at is conservative: any token issued before the account was created
# is always invalid, so this is a safe lower bound.
await _safe_execute(
conn,
"UPDATE users SET password_changed_at = created_at WHERE password_changed_at IS NULL",
)
async with conn.begin_nested():
await conn.execute(text("UPDATE users SET password_changed_at = created_at WHERE password_changed_at IS NULL"))
# Migration: Provenance columns on library_files for MakerWorld imports.
# source_url is indexed so "already imported" dedupe lookups stay O(log N)
@@ -1542,12 +1651,23 @@ async def run_migrations(conn):
# every restart. Dedupe (keep lowest id per key) and add the missing unique index
# before seeding. Safe/idempotent on both dialects — fresh installs already have
# no dupes and `create_all` already emits the index.
await _safe_execute(
conn,
"DELETE FROM settings WHERE id NOT IN (SELECT MIN(id) FROM settings GROUP BY key)",
)
async with conn.begin_nested():
await conn.execute(text("DELETE FROM settings WHERE id NOT IN (SELECT MIN(id) FROM settings GROUP BY key)"))
await _safe_execute(conn, "CREATE UNIQUE INDEX IF NOT EXISTS ix_settings_key ON settings(key)")
# Migration: Normalise provider_email to lowercase (SEC-3).
# Required for Entra ID where UPN/email claims may arrive in mixed case.
# LOWER() is supported by both SQLite and PostgreSQL; the UPDATE is idempotent.
# Executed directly (not via _safe_execute) so any column-reference failure
# is always fatal and never silently swallowed.
async with conn.begin_nested():
await conn.execute(
text(
"UPDATE user_oidc_links SET provider_email = LOWER(provider_email) "
"WHERE provider_email IS NOT NULL AND provider_email != LOWER(provider_email)"
)
)
# Seed default settings keys that must exist on fresh install
default_settings = [
("advanced_auth_enabled", "false"),
+24 -1
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func
from sqlalchemy import Boolean, CheckConstraint, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from backend.app.core.database import Base
@@ -21,6 +21,15 @@ class OIDCProvider(Base):
"""
__tablename__ = "oidc_providers"
__table_args__ = (
# DB-level enforcement of SEC-1/SEC-6: auto_link is only safe when
# require_email_verified=True AND email_claim='email'. Enforced on new
# installations; existing tables get this via the PostgreSQL-only migration.
CheckConstraint(
"auto_link_existing_accounts = FALSE OR (require_email_verified = TRUE AND email_claim = 'email')",
name="ck_auto_link_requires_verified_email_claim",
),
)
id: Mapped[int] = mapped_column(primary_key=True)
# Human-readable name shown on the login button (e.g. "PocketID", "Google")
@@ -50,6 +59,20 @@ class OIDCProvider(Base):
# operators must explicitly opt-in to prevent an attacker-controlled IdP from
# silently hijacking local accounts via email matching (M-2 fix).
auto_link_existing_accounts: Mapped[bool] = mapped_column(Boolean, default=False)
# JWT claim name used as the email identity (default "email").
# Set to "preferred_username" or "upn" for Azure Entra ID, which does not send
# email_verified — using a custom claim skips the email_verified check entirely
# and is the recommended Azure configuration.
# Has no interaction with require_email_verified when set to a non-"email" value:
# custom claims never perform an email_verified check regardless of that setting.
email_claim: Mapped[str] = mapped_column(String(64), default="email")
# When True (default), the "email" claim is only trusted when email_verified=True.
# Set to False to accept the email even when email_verified is absent — required
# for providers like Azure Entra ID that never send email_verified and where a
# custom claim (email_claim != "email") is not preferred.
# Has no effect when email_claim is not "email": the custom-claim path never
# performs an email_verified check regardless of this setting.
require_email_verified: Mapped[bool] = mapped_column(Boolean, default=True)
# Optional icon URL (SVG/PNG) shown on the login button
icon_url: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+56 -3
View File
@@ -1,7 +1,7 @@
import re
from typing import Literal
from pydantic import BaseModel, Field, field_validator
from pydantic import BaseModel, Field, field_validator, model_validator
def _validate_password_complexity(v: str) -> str:
@@ -296,6 +296,19 @@ class AdminDisable2FARequest(BaseModel):
# ---------------------------------------------------------------------------
AUTO_LINK_REQUIREMENTS_ERROR = (
"auto_link_existing_accounts requires require_email_verified=True and email_claim='email'"
)
def _validate_email_claim_name(v: str) -> str:
# Accepts only alphanumeric/underscore/hyphen claim names starting with a letter —
# prevents log injection and limits the attack surface of operator-supplied claim names.
if not re.fullmatch(r"[a-zA-Z][a-zA-Z0-9_\-]{0,63}", v):
raise ValueError("Invalid claim name")
return v
def _validate_icon_url(v: str | None) -> str | None:
"""Reject non-HTTPS icon URLs to prevent SSRF / mixed-content issues."""
if v is None:
@@ -355,27 +368,45 @@ class OIDCProviderCreate(BaseModel):
is_enabled: bool = True
auto_create_users: bool = False
auto_link_existing_accounts: bool = False # M-2: conservative default, opt-in only
email_claim: str = Field(default="email", max_length=64)
require_email_verified: bool = True
icon_url: str | None = None
@field_validator("issuer_url")
@classmethod
def validate_issuer_url(cls, v: str) -> str:
result = _validate_issuer_url(v)
assert result is not None
if result is None:
raise ValueError("issuer_url is required")
return result
@field_validator("scopes")
@classmethod
def validate_scopes(cls, v: str) -> str:
result = _validate_scopes(v)
assert result is not None
if result is None:
raise ValueError("scopes is required")
return result
@field_validator("email_claim")
@classmethod
def validate_email_claim(cls, v: str) -> str:
return _validate_email_claim_name(v)
@field_validator("icon_url")
@classmethod
def validate_icon_url(cls, v: str | None) -> str | None:
return _validate_icon_url(v)
# SEC-1 + SEC-6: auto_link requires both require_email_verified=True AND email_claim="email".
# Fall B (require_email_verified=False) accepts absent email_verified → account-takeover risk.
# Fall C (custom claim) skips email_verified entirely → same risk.
@model_validator(mode="after")
def check_auto_link_requires_verified(self) -> "OIDCProviderCreate":
if self.auto_link_existing_accounts and (not self.require_email_verified or self.email_claim != "email"):
raise ValueError(AUTO_LINK_REQUIREMENTS_ERROR)
return self
class OIDCProviderUpdate(BaseModel):
name: str | None = Field(default=None, max_length=100)
@@ -392,6 +423,8 @@ class OIDCProviderUpdate(BaseModel):
is_enabled: bool | None = None
auto_create_users: bool | None = None
auto_link_existing_accounts: bool | None = None
email_claim: str | None = Field(default=None, max_length=64)
require_email_verified: bool | None = None
icon_url: str | None = None
@field_validator("scopes")
@@ -399,11 +432,29 @@ class OIDCProviderUpdate(BaseModel):
def validate_scopes(cls, v: str | None) -> str | None:
return _validate_scopes(v)
@field_validator("email_claim")
@classmethod
def validate_email_claim(cls, v: str | None) -> str | None:
if v is None:
return None
return _validate_email_claim_name(v)
@field_validator("icon_url")
@classmethod
def validate_icon_url(cls, v: str | None) -> str | None:
return _validate_icon_url(v)
# SEC-1 + SEC-6 (schema-level): blocks only when both conflicting fields arrive
# in the same request. Partial updates spanning two requests are caught by the
# Combined-State-Guard in the route handler after the setattr loop.
@model_validator(mode="after")
def check_auto_link_requires_verified(self) -> "OIDCProviderUpdate":
if self.auto_link_existing_accounts is True and (
self.require_email_verified is False or (self.email_claim is not None and self.email_claim != "email")
):
raise ValueError(AUTO_LINK_REQUIREMENTS_ERROR)
return self
class OIDCProviderResponse(BaseModel):
id: int
@@ -414,6 +465,8 @@ class OIDCProviderResponse(BaseModel):
is_enabled: bool
auto_create_users: bool
auto_link_existing_accounts: bool = False
email_claim: str = "email"
require_email_verified: bool = True
icon_url: str | None = None
class Config:
+777
View File
@@ -18,8 +18,11 @@ Tests the full request/response cycle for:
from __future__ import annotations
import secrets
import time
from datetime import datetime, timedelta, timezone
from unittest.mock import patch
import jwt as pyjwt
import pyotp
import pytest
from httpx import AsyncClient
@@ -3263,3 +3266,777 @@ class TestOIDCCallbackCodeLength:
follow_redirects=False,
)
assert resp.status_code == 422, "2049-char state must be rejected by Pydantic"
# ---------------------------------------------------------------------------
# Helpers shared by TestOIDCEmailClaimResolution
# ---------------------------------------------------------------------------
async def _run_oidc_callback(
async_client: AsyncClient,
db_session: AsyncSession,
*,
provider_id: int,
claims: dict,
private_pem: bytes,
jwks_data: dict,
issuer: str,
client_id: str,
) -> str:
"""Run a full OIDC callback flow and return the redirect location."""
nonce = secrets.token_urlsafe(16)
now = int(time.time())
token_claims = {
"sub": claims.get("sub", f"sub-{secrets.token_hex(8)}"),
"iss": issuer,
"aud": client_id,
"nonce": nonce,
"iat": now,
"exp": now + 300,
**{k: v for k, v in claims.items() if k not in ("sub",)},
}
id_token = pyjwt.encode(token_claims, private_pem, algorithm="RS256", headers={"kid": "test-kid-1"})
state = secrets.token_urlsafe(32)
code_verifier = secrets.token_urlsafe(48)
db_session.add(
AuthEphemeralToken(
token=state,
token_type="oidc_state",
provider_id=provider_id,
nonce=nonce,
code_verifier=code_verifier,
expires_at=datetime.now(timezone.utc) + timedelta(minutes=5),
)
)
await db_session.commit()
discovery_doc = {
"issuer": issuer,
"authorization_endpoint": f"{issuer}/auth",
"token_endpoint": f"{issuer}/token",
"jwks_uri": f"{issuer}/.well-known/jwks.json",
}
token_response = {"access_token": "mock-access", "token_type": "Bearer", "id_token": id_token}
class _R:
def __init__(self, data):
self._data = data
self.status_code = 200
self.is_success = True
self.text = str(data)
def json(self):
return self._data
def raise_for_status(self):
pass
class _C:
def __init__(self, *a, **kw):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *a):
pass
async def get(self, url, **kw):
return _R(jwks_data if "jwks" in url else discovery_doc)
async def post(self, url, **kw):
return _R(token_response)
with patch("backend.app.api.routes.mfa.httpx.AsyncClient", _C):
resp = await async_client.get(
f"/api/v1/auth/oidc/callback?code=test-code&state={state}",
follow_redirects=False,
)
return resp.headers.get("location", "")
class TestOIDCEmailClaimResolution:
"""Three-case email resolution logic: Fall A / Fall B / Fall C."""
# ── shared helpers ────────────────────────────────────────────────────────
async def _create_provider(
self,
async_client: AsyncClient,
admin_token: str,
issuer: str,
client_id: str,
*,
email_claim: str = "email",
require_email_verified: bool = True,
auto_link_existing_accounts: bool = False,
suffix: str = "",
) -> int:
resp = await async_client.post(
"/api/v1/auth/oidc/providers",
json={
"name": f"TestIdP-{suffix or secrets.token_hex(4)}",
"issuer_url": issuer,
"client_id": client_id,
"client_secret": "sec",
"scopes": "openid email profile",
"is_enabled": True,
"auto_create_users": True,
"auto_link_existing_accounts": auto_link_existing_accounts,
"email_claim": email_claim,
"require_email_verified": require_email_verified,
},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert resp.status_code == 201, resp.text
return resp.json()["id"]
async def _get_oidc_link(self, db_session: AsyncSession, provider_id: int, sub: str):
from sqlalchemy import select
from backend.app.models.oidc_provider import UserOIDCLink
result = await db_session.execute(
select(UserOIDCLink)
.where(UserOIDCLink.provider_id == provider_id)
.where(UserOIDCLink.provider_user_id == sub)
)
return result.scalar_one_or_none()
# ── Parametrized matrix: Fall A / Fall B / Fall C ─────────────────────────
@pytest.mark.asyncio
@pytest.mark.integration
@pytest.mark.parametrize(
"email_claim,require_ev,claims,expected",
[
# Fall A: standard claim + require_ev=True (default)
("email", True, {"email": "fa@example.com", "email_verified": True}, "fa@example.com"),
("email", True, {"email": "fa@example.com", "email_verified": False}, None),
("email", True, {"email": "fa@example.com"}, None), # Azure Entra with default config
# Fall A + SEC-2: malformed email claim rejected even when email_verified=True
("email", True, {"email": "notanemail", "email_verified": True}, None),
# Fall B: standard claim + require_ev=False (Azure Entra permissive)
("email", False, {"email": "fb@example.com", "email_verified": True}, "fb@example.com"),
("email", False, {"email": "fb@example.com", "email_verified": False}, None),
("email", False, {"email": "azure@company.com"}, "azure@company.com"), # ev absent → kept
# Fall B + SEC-2: malformed email claim rejected in permissive mode (ev absent)
("email", False, {"email": "user@nodot"}, None),
# Fall B + SEC-2: shape check fires before email_verified=False drop
("email", False, {"email": "notanemail", "email_verified": False}, None),
# Fall C: custom claim (preferred_username) — no email_verified check
("preferred_username", True, {"preferred_username": "User@Company.COM"}, "user@company.com"),
("preferred_username", True, {"preferred_username": " User@EXAMPLE.COM "}, "user@example.com"),
("preferred_username", True, {"preferred_username": "justausername"}, None),
("preferred_username", True, {"preferred_username": "@"}, None), # SEC-2: "@" only
("preferred_username", True, {"preferred_username": "@domain.com"}, None), # SEC-2: empty local
("preferred_username", True, {"preferred_username": "user@"}, None), # SEC-2: empty domain
("preferred_username", True, {"preferred_username": "user@nodot"}, None), # SEC-2: no dot in domain
("preferred_username", True, {}, None), # claim absent
# Fall C: email_verified=False present alongside custom claim — must NOT suppress the email
("preferred_username", True, {"preferred_username": "user@co.com", "email_verified": False}, "user@co.com"),
],
ids=[
"fall-a-ev-true",
"fall-a-ev-false",
"fall-a-ev-absent",
"fall-a-malformed-email",
"fall-b-ev-true",
"fall-b-ev-false",
"fall-b-ev-absent",
"fall-b-malformed-email",
"fall-b-malformed-email-ev-false",
"fall-c-valid-upn",
"fall-c-lowercase-strip",
"fall-c-no-at",
"fall-c-at-only",
"fall-c-empty-local",
"fall-c-empty-domain",
"fall-c-no-dot-in-domain",
"fall-c-claim-absent",
"fall-c-ev-false-ignored",
],
)
async def test_email_resolution_matrix(
self,
async_client: AsyncClient,
db_session: AsyncSession,
email_claim: str,
require_ev: bool,
claims: dict,
expected: str | None,
):
"""C4: Verify link exists AND check provider_email — avoids false-passing on callback failure."""
issuer = "https://matrix.test"
client_id = "matrix-client"
admin_token = await _setup_and_login(async_client, "matrix_adm", "Matrix123!")
private_pem, jwks_data = _make_test_rsa_key()
provider_id = await self._create_provider(
async_client,
admin_token,
issuer,
client_id,
email_claim=email_claim,
require_email_verified=require_ev,
suffix="matrix",
)
sub = f"sub-matrix-{secrets.token_hex(6)}"
await _run_oidc_callback(
async_client,
db_session,
provider_id=provider_id,
claims={"sub": sub, **claims},
private_pem=private_pem,
jwks_data=jwks_data,
issuer=issuer,
client_id=client_id,
)
db_session.expire_all()
link = await self._get_oidc_link(db_session, provider_id, sub)
assert link is not None, "UserOIDCLink must be created even when email is dropped"
assert link.provider_email == expected
# ── Security: auto_link guards (CREATE endpoint) ──────────────────────────
@pytest.mark.asyncio
@pytest.mark.integration
async def test_auto_link_blocked_with_require_ev_false(self, async_client: AsyncClient):
"""SEC-1: auto_link + require_email_verified=False must be rejected at schema level (422)."""
admin_token = await _setup_and_login(async_client, "sec1_adm", "Sec1Adm123!")
resp = await async_client.post(
"/api/v1/auth/oidc/providers",
json={
"name": "SEC1-Test",
"issuer_url": "https://sec1.test",
"client_id": "sec1-client",
"client_secret": "sec",
"scopes": "openid email profile",
"auto_link_existing_accounts": True,
"require_email_verified": False,
},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert resp.status_code == 422
@pytest.mark.asyncio
@pytest.mark.integration
async def test_auto_link_blocked_with_custom_claim_create(self, async_client: AsyncClient):
"""SEC-6: auto_link + email_claim!='email' must be rejected at schema level on CREATE (422)."""
admin_token = await _setup_and_login(async_client, "sec6c_adm", "Sec6CAdm123!")
resp = await async_client.post(
"/api/v1/auth/oidc/providers",
json={
"name": "SEC6-Create-Test",
"issuer_url": "https://sec6c.test",
"client_id": "sec6c-client",
"client_secret": "sec",
"scopes": "openid email profile",
"auto_link_existing_accounts": True,
"email_claim": "upn",
},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert resp.status_code == 422
@pytest.mark.asyncio
@pytest.mark.integration
async def test_auto_link_blocked_with_custom_claim_update(self, async_client: AsyncClient):
"""SEC-6: auto_link=True + email_claim='upn' in same UPDATE request → 422 (T4)."""
admin_token = await _setup_and_login(async_client, "sec6u_adm", "Sec6UAdm123!")
create_resp = await async_client.post(
"/api/v1/auth/oidc/providers",
json={
"name": "SEC6-Update-Test",
"issuer_url": "https://sec6u.test",
"client_id": "sec6u-client",
"client_secret": "sec",
"scopes": "openid email profile",
},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert create_resp.status_code == 201
provider_id = create_resp.json()["id"]
resp = await async_client.put(
f"/api/v1/auth/oidc/providers/{provider_id}",
json={"auto_link_existing_accounts": True, "email_claim": "upn"},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert resp.status_code == 422
# ── Combined-State-Guard (partial updates across two requests) ─────────────
@pytest.mark.asyncio
@pytest.mark.integration
async def test_partial_update_guard_require_ev(self, async_client: AsyncClient):
"""SEC-1 Combined-State-Guard: require_ev=False then auto_link=True → 422 (T1 require_ev path)."""
admin_token = await _setup_and_login(async_client, "pg_rev_adm", "PgRev123!")
create_resp = await async_client.post(
"/api/v1/auth/oidc/providers",
json={
"name": "PG-RequireEV-Test",
"issuer_url": "https://pg-rev.test",
"client_id": "pg-rev-client",
"client_secret": "sec",
"scopes": "openid email profile",
},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert create_resp.status_code == 201
provider_id = create_resp.json()["id"]
upd1 = await async_client.put(
f"/api/v1/auth/oidc/providers/{provider_id}",
json={"require_email_verified": False},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert upd1.status_code == 200
upd2 = await async_client.put(
f"/api/v1/auth/oidc/providers/{provider_id}",
json={"auto_link_existing_accounts": True},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert upd2.status_code == 422
@pytest.mark.asyncio
@pytest.mark.integration
async def test_partial_update_guard_email_claim(self, async_client: AsyncClient):
"""SEC-6 Combined-State-Guard: email_claim='upn' then auto_link=True → 422 (T1 email_claim path)."""
admin_token = await _setup_and_login(async_client, "pg_ec_adm", "PgEc123!")
create_resp = await async_client.post(
"/api/v1/auth/oidc/providers",
json={
"name": "PG-EmailClaim-Test",
"issuer_url": "https://pg-ec.test",
"client_id": "pg-ec-client",
"client_secret": "sec",
"scopes": "openid email profile",
},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert create_resp.status_code == 201
provider_id = create_resp.json()["id"]
upd1 = await async_client.put(
f"/api/v1/auth/oidc/providers/{provider_id}",
json={"email_claim": "upn"},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert upd1.status_code == 200
upd2 = await async_client.put(
f"/api/v1/auth/oidc/providers/{provider_id}",
json={"auto_link_existing_accounts": True},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert upd2.status_code == 422
@pytest.mark.asyncio
@pytest.mark.integration
async def test_partial_update_guard_inverse_order(self, async_client: AsyncClient):
"""T2: auto_link=True first (valid), then require_ev=False → Combined-State-Guard fires (422)."""
admin_token = await _setup_and_login(async_client, "pg_inv_adm", "PgInv123!")
create_resp = await async_client.post(
"/api/v1/auth/oidc/providers",
json={
"name": "PG-Inverse-Test",
"issuer_url": "https://pg-inv.test",
"client_id": "pg-inv-client",
"client_secret": "sec",
"scopes": "openid email profile",
},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert create_resp.status_code == 201
provider_id = create_resp.json()["id"]
# auto_link=True is safe when require_ev=True (default)
upd1 = await async_client.put(
f"/api/v1/auth/oidc/providers/{provider_id}",
json={"auto_link_existing_accounts": True},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert upd1.status_code == 200
# Disabling require_ev with auto_link already on → unsafe combined state
upd2 = await async_client.put(
f"/api/v1/auth/oidc/providers/{provider_id}",
json={"require_email_verified": False},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert upd2.status_code == 422
# ── Low5: Response fields verified ───────────────────────────────────────
@pytest.mark.asyncio
@pytest.mark.integration
async def test_create_response_includes_new_fields(self, async_client: AsyncClient):
"""Low5: OIDCProviderResponse must include email_claim and require_email_verified."""
admin_token = await _setup_and_login(async_client, "resp_adm", "Resp123!")
resp = await async_client.post(
"/api/v1/auth/oidc/providers",
json={
"name": "ResponseFields-Test",
"issuer_url": "https://resp.test",
"client_id": "resp-client",
"client_secret": "sec",
"scopes": "openid email profile",
"email_claim": "preferred_username",
"require_email_verified": False,
},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert resp.status_code == 201
data = resp.json()
assert data["email_claim"] == "preferred_username"
assert data["require_email_verified"] is False
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_response_reflects_new_fields(self, async_client: AsyncClient):
"""Low5: PUT response must reflect updated email_claim and require_email_verified."""
admin_token = await _setup_and_login(async_client, "upd_resp_adm", "UpdResp123!")
create_resp = await async_client.post(
"/api/v1/auth/oidc/providers",
json={
"name": "UpdateResponse-Test",
"issuer_url": "https://upd-resp.test",
"client_id": "upd-resp-client",
"client_secret": "sec",
"scopes": "openid email profile",
},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert create_resp.status_code == 201
provider_id = create_resp.json()["id"]
upd = await async_client.put(
f"/api/v1/auth/oidc/providers/{provider_id}",
json={"email_claim": "upn", "require_email_verified": True},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert upd.status_code == 200
data = upd.json()
assert data["email_claim"] == "upn"
assert data["require_email_verified"] is True
# ===========================================================================
# TestOIDCEmailClaimValidation — T2: email_claim field validator coverage
# ===========================================================================
class TestOIDCEmailClaimValidation:
"""Schema-level validation for the email_claim field."""
@pytest.mark.asyncio
@pytest.mark.integration
async def test_invalid_claim_name_dot_rejected(self, async_client: AsyncClient):
"""email_claim with a dot (log-injection risk) must be rejected."""
admin_token = await _setup_and_login(async_client, "ecv_adm1", "Ecv123!")
resp = await async_client.post(
"/api/v1/auth/oidc/providers",
json={
"name": "ECVTest1",
"issuer_url": "https://ecv1.test",
"client_id": "ecv1",
"client_secret": "sec",
"scopes": "openid email",
"email_claim": "email.address",
},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert resp.status_code == 422
@pytest.mark.asyncio
@pytest.mark.integration
async def test_invalid_claim_name_starts_with_digit_rejected(self, async_client: AsyncClient):
admin_token = await _setup_and_login(async_client, "ecv_adm2", "Ecv123!")
resp = await async_client.post(
"/api/v1/auth/oidc/providers",
json={
"name": "ECVTest2",
"issuer_url": "https://ecv2.test",
"client_id": "ecv2",
"client_secret": "sec",
"scopes": "openid email",
"email_claim": "1invalid",
},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert resp.status_code == 422
@pytest.mark.asyncio
@pytest.mark.integration
async def test_invalid_claim_name_newline_rejected(self, async_client: AsyncClient):
"""T2 regex-bug guard: re.fullmatch must reject trailing newline."""
admin_token = await _setup_and_login(async_client, "ecv_adm3", "Ecv123!")
resp = await async_client.post(
"/api/v1/auth/oidc/providers",
json={
"name": "ECVTest3",
"issuer_url": "https://ecv3.test",
"client_id": "ecv3",
"client_secret": "sec",
"scopes": "openid email",
"email_claim": "email\n",
},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert resp.status_code == 422
@pytest.mark.asyncio
@pytest.mark.integration
async def test_claim_name_65_chars_rejected(self, async_client: AsyncClient):
"""email_claim longer than 64 characters must be rejected."""
admin_token = await _setup_and_login(async_client, "ecv_adm4", "Ecv123!")
resp = await async_client.post(
"/api/v1/auth/oidc/providers",
json={
"name": "ECVTest4",
"issuer_url": "https://ecv4.test",
"client_id": "ecv4",
"client_secret": "sec",
"scopes": "openid email",
"email_claim": "a" * 65,
},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert resp.status_code == 422
@pytest.mark.asyncio
@pytest.mark.integration
async def test_valid_claim_name_accepted(self, async_client: AsyncClient):
"""Valid claim names like preferred_username and upn must be accepted."""
admin_token = await _setup_and_login(async_client, "ecv_adm5", "Ecv123!")
for claim in ("preferred_username", "upn", "email", "emailAddress"):
resp = await async_client.post(
"/api/v1/auth/oidc/providers",
json={
"name": f"ECVTest-{claim[:8]}",
"issuer_url": f"https://ecv-{claim[:8]}.test",
"client_id": f"ecv-{claim[:8]}",
"client_secret": "sec",
"scopes": "openid email",
"email_claim": claim,
},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert resp.status_code == 201, f"claim {claim!r} was rejected: {resp.text}"
# ===========================================================================
# TestOIDCEmailResolutionExtra — T1 / T3 / T4 additional coverage
# ===========================================================================
async def _create_provider_via_api(
async_client: AsyncClient,
admin_token: str,
issuer: str,
client_id: str,
*,
email_claim: str = "email",
require_email_verified: bool = True,
suffix: str = "",
) -> int:
resp = await async_client.post(
"/api/v1/auth/oidc/providers",
json={
"name": f"TestIdP-extra-{suffix or secrets.token_hex(4)}",
"issuer_url": issuer,
"client_id": client_id,
"client_secret": "sec",
"scopes": "openid email profile",
"is_enabled": True,
"auto_create_users": True,
"email_claim": email_claim,
"require_email_verified": require_email_verified,
},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert resp.status_code == 201, resp.text
return resp.json()["id"]
class TestOIDCEmailResolutionExtra:
"""T1: isinstance guard, T3: SEC-3 normalisation for Fall A/B, T4: inverse Combined-State-Guard."""
@pytest.mark.asyncio
@pytest.mark.integration
async def test_non_string_claim_value_drops_email(
self,
async_client: AsyncClient,
db_session: AsyncSession,
):
"""T1: A non-string email_claim value (list) must be silently dropped — no crash."""
private_pem, jwks_data = _make_test_rsa_key()
issuer = "https://nonstring-test.example"
client_id = "nonstring-client"
admin_token = await _setup_and_login(async_client, "nonstr_adm", "Nonstr123!")
provider_id = await _create_provider_via_api(
async_client,
admin_token,
issuer,
client_id,
email_claim="preferred_username",
require_email_verified=False,
suffix="nonstr",
)
from sqlalchemy import select
from backend.app.models.oidc_provider import UserOIDCLink
# IdP sends preferred_username as a list (non-string) — must not crash
location = await _run_oidc_callback(
async_client,
db_session,
provider_id=provider_id,
claims={"sub": "nonstr-sub-1", "preferred_username": ["user@example.com"]},
private_pem=private_pem,
jwks_data=jwks_data,
issuer=issuer,
client_id=client_id,
)
assert "internal_error" not in location, f"Unexpected error redirect: {location}"
link_result = await db_session.execute(
select(UserOIDCLink)
.where(UserOIDCLink.provider_id == provider_id)
.where(UserOIDCLink.provider_user_id == "nonstr-sub-1")
)
link = link_result.scalar_one_or_none()
assert link is not None
assert link.provider_email is None # list value dropped
@pytest.mark.asyncio
@pytest.mark.integration
async def test_fall_a_sec3_normalisation(
self,
async_client: AsyncClient,
db_session: AsyncSession,
):
"""T3: Fall A — uppercase + whitespace in email claim must be normalised to lowercase."""
private_pem, jwks_data = _make_test_rsa_key()
issuer = "https://sec3a-test.example"
client_id = "sec3a-client"
admin_token = await _setup_and_login(async_client, "sec3a_adm", "Sec3a123!")
provider_id = await _create_provider_via_api(
async_client,
admin_token,
issuer,
client_id,
email_claim="email",
require_email_verified=True,
suffix="sec3a",
)
from sqlalchemy import select
from backend.app.models.oidc_provider import UserOIDCLink
location = await _run_oidc_callback(
async_client,
db_session,
provider_id=provider_id,
claims={"sub": "sec3a-sub-1", "email": " USER@EXAMPLE.COM ", "email_verified": True},
private_pem=private_pem,
jwks_data=jwks_data,
issuer=issuer,
client_id=client_id,
)
assert "internal_error" not in location
link_result = await db_session.execute(
select(UserOIDCLink)
.where(UserOIDCLink.provider_id == provider_id)
.where(UserOIDCLink.provider_user_id == "sec3a-sub-1")
)
link = link_result.scalar_one_or_none()
assert link is not None
assert link.provider_email == "user@example.com"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_fall_b_sec3_normalisation(
self,
async_client: AsyncClient,
db_session: AsyncSession,
):
"""T3: Fall B — uppercase + whitespace in email claim must be normalised to lowercase."""
private_pem, jwks_data = _make_test_rsa_key()
issuer = "https://sec3b-test.example"
client_id = "sec3b-client"
admin_token = await _setup_and_login(async_client, "sec3b_adm", "Sec3b123!")
provider_id = await _create_provider_via_api(
async_client,
admin_token,
issuer,
client_id,
email_claim="email",
require_email_verified=False,
suffix="sec3b",
)
from sqlalchemy import select
from backend.app.models.oidc_provider import UserOIDCLink
location = await _run_oidc_callback(
async_client,
db_session,
provider_id=provider_id,
claims={"sub": "sec3b-sub-1", "email": " USER@EXAMPLE.COM "},
private_pem=private_pem,
jwks_data=jwks_data,
issuer=issuer,
client_id=client_id,
)
assert "internal_error" not in location
link_result = await db_session.execute(
select(UserOIDCLink)
.where(UserOIDCLink.provider_id == provider_id)
.where(UserOIDCLink.provider_user_id == "sec3b-sub-1")
)
link = link_result.scalar_one_or_none()
assert link is not None
assert link.provider_email == "user@example.com"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_combined_state_guard_email_claim_inverse_order(self, async_client: AsyncClient):
"""T4: Combined-State-Guard — set auto_link=True first, then switch email_claim to custom."""
admin_token = await _setup_and_login(async_client, "inv_ec_adm", "InvEc123!")
create_resp = await async_client.post(
"/api/v1/auth/oidc/providers",
json={
"name": "InvEcTest",
"issuer_url": "https://inv-ec.test",
"client_id": "inv-ec",
"client_secret": "sec",
"scopes": "openid email",
},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert create_resp.status_code == 201
provider_id = create_resp.json()["id"]
# First: enable auto_link (safe — email_claim="email", require_ev=True)
upd1 = await async_client.put(
f"/api/v1/auth/oidc/providers/{provider_id}",
json={"auto_link_existing_accounts": True},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert upd1.status_code == 200
# Second: switch email_claim to custom while auto_link is on → unsafe combined state
upd2 = await async_client.put(
f"/api/v1/auth/oidc/providers/{provider_id}",
json={"email_claim": "preferred_username"},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert upd2.status_code == 422
+358 -4
View File
@@ -163,13 +163,11 @@ class TestSafeExecutePattern:
"""Verify _safe_execute catches both OperationalError and ProgrammingError."""
from sqlalchemy.exc import OperationalError, ProgrammingError
# These are the exception types _safe_execute must catch
# (verified by reading the source — actual integration tested by 1509 unit tests)
for exc_type in (OperationalError, ProgrammingError):
try:
raise exc_type("test", [], Exception("column already exists"))
except (OperationalError, ProgrammingError):
pass # This is what _safe_execute does
pass
def test_safe_execute_would_not_catch_integrity_error(self):
"""IntegrityError should NOT be caught by _safe_execute."""
@@ -179,4 +177,360 @@ class TestSafeExecutePattern:
try:
raise IntegrityError("test", [], Exception("unique violation"))
except (OperationalError, ProgrammingError):
pass # _safe_execute only catches these two
pass
@pytest.mark.asyncio
async def test_safe_execute_reraises_non_idempotency_errors(self):
"""Non-idempotency errors must propagate so startup fails loudly."""
from sqlalchemy.exc import OperationalError
from sqlalchemy.ext.asyncio import create_async_engine
from backend.app.core.database import _safe_execute
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as conn:
with pytest.raises(OperationalError):
await _safe_execute(conn, "SELECT * FROM nonexistent_table_xyz")
await engine.dispose()
@pytest.mark.asyncio
async def test_safe_execute_swallows_already_exists(self):
"""Idempotency errors (already exists) must be silently ignored."""
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
from backend.app.core.database import _safe_execute
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as conn:
await conn.execute(text("CREATE TABLE t (id INTEGER)"))
# Second CREATE must not raise
await _safe_execute(conn, "CREATE TABLE t (id INTEGER)")
await engine.dispose()
@pytest.mark.asyncio
async def test_provider_email_lowercasing_migration(self):
"""SEC-3: provider_email normalisation lowers mixed-case values, leaves NULL intact.
The production migration runs this UPDATE directly (not via _safe_execute)
so any failure is always fatal and visible at startup.
"""
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as conn:
await conn.execute(text("CREATE TABLE user_oidc_links (id INTEGER PRIMARY KEY, provider_email TEXT)"))
await conn.execute(text("INSERT INTO user_oidc_links VALUES (1, 'User@Example.COM')"))
await conn.execute(text("INSERT INTO user_oidc_links VALUES (2, 'already@lower.com')"))
await conn.execute(text("INSERT INTO user_oidc_links VALUES (3, NULL)"))
async with conn.begin_nested():
await conn.execute(
text(
"UPDATE user_oidc_links SET provider_email = LOWER(provider_email) "
"WHERE provider_email IS NOT NULL AND provider_email != LOWER(provider_email)"
)
)
result = await conn.execute(text("SELECT provider_email FROM user_oidc_links ORDER BY id"))
rows = [r[0] for r in result.fetchall()]
await engine.dispose()
assert rows[0] == "user@example.com"
assert rows[1] == "already@lower.com"
assert rows[2] is None
@pytest.mark.asyncio
async def test_safe_execute_swallows_no_such_column_for_rename(self):
"""'no such column' is swallowed for RENAME COLUMN idempotency.
When a column has already been renamed, re-running the RENAME COLUMN
migration raises 'no such column' — that must be silently swallowed.
DML safety is guaranteed by never passing DML through _safe_execute.
"""
from sqlalchemy.ext.asyncio import create_async_engine
from backend.app.core.database import _safe_execute
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as conn:
await conn.execute(__import__("sqlalchemy").text("CREATE TABLE t (id INTEGER, new_col INTEGER)"))
# Column 'old_col' does not exist — simulates re-running a RENAME COLUMN migration
# Must NOT raise.
await _safe_execute(conn, "ALTER TABLE t RENAME COLUMN old_col TO new_col")
await engine.dispose()
@pytest.mark.asyncio
async def test_safe_execute_swallows_does_not_exist_for_rename_postgres(self):
"""'does not exist' (PostgreSQL UndefinedColumnError) is swallowed for RENAME COLUMN idempotency."""
from unittest.mock import AsyncMock, MagicMock
from sqlalchemy.exc import ProgrammingError
from backend.app.core.database import _safe_execute
fake_exc = ProgrammingError('column "quantity_printed" does not exist', [], Exception())
nested_cm = MagicMock()
nested_cm.__aenter__ = AsyncMock(return_value=nested_cm)
nested_cm.execute = AsyncMock(side_effect=fake_exc)
nested_cm.__aexit__ = AsyncMock(return_value=False)
mock_conn = MagicMock()
mock_conn.begin_nested.return_value = nested_cm
mock_conn.execute = AsyncMock(side_effect=fake_exc)
# Must NOT raise — "does not exist" is in the swallow-list
await _safe_execute(
mock_conn, "ALTER TABLE project_bom_items RENAME COLUMN quantity_printed TO quantity_acquired"
)
@pytest.mark.asyncio
async def test_safe_execute_swallows_duplicate_key(self):
"""'duplicate key' errors (PostgreSQL unique-constraint violations on re-run)
must be silently swallowed for idempotent DDL migrations."""
from unittest.mock import AsyncMock, MagicMock
from sqlalchemy.exc import OperationalError
from backend.app.core.database import _safe_execute
fake_exc = OperationalError("duplicate key value violates unique constraint", [], Exception())
# begin_nested() is called synchronously (not awaited) and returns an
# async context manager. Use MagicMock so the call returns a regular
# object, then attach __aenter__/__aexit__ for the async with protocol.
nested_cm = MagicMock()
nested_cm.__aenter__ = AsyncMock(return_value=nested_cm)
# Raise on execute inside the context, simulating PG duplicate key
nested_cm.execute = AsyncMock(side_effect=fake_exc)
nested_cm.__aexit__ = AsyncMock(return_value=False)
mock_conn = MagicMock()
mock_conn.begin_nested.return_value = nested_cm
mock_conn.execute = AsyncMock(side_effect=fake_exc)
# Must NOT raise — "duplicate key" is in the swallow-list
await _safe_execute(mock_conn, "CREATE UNIQUE INDEX ...")
@pytest.mark.asyncio
async def test_check_constraint_false_true_on_sqlite(self):
"""CheckConstraint with FALSE/TRUE literals is enforced on SQLite (3.23+)."""
from sqlalchemy import text
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import create_async_engine
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as conn:
await conn.execute(
text("""
CREATE TABLE ck_test (
id INTEGER PRIMARY KEY,
auto_link BOOLEAN,
require_ev BOOLEAN,
email_claim TEXT,
CHECK (auto_link = FALSE OR (require_ev = TRUE AND email_claim = 'email'))
)
""")
)
# Valid: auto_link=0 (FALSE)
await conn.execute(text("INSERT INTO ck_test VALUES (1, 0, 0, 'upn')"))
# Valid: auto_link=1, require_ev=1, email_claim='email'
await conn.execute(text("INSERT INTO ck_test VALUES (2, 1, 1, 'email')"))
async with engine.begin() as conn:
# Invalid: auto_link=1 but conditions not met
with pytest.raises(IntegrityError):
await conn.execute(text("INSERT INTO ck_test VALUES (3, 1, 0, 'email')"))
await engine.dispose()
@pytest.mark.asyncio
async def test_auto_link_sec1_backfill_resets_unsafe_rows(self):
"""SEC-1 backfill resets auto_link=TRUE on rows with unsafe combined state.
Three cases:
1. auto_link=TRUE + require_ev=FALSE → reset to FALSE (unsafe: permissive mode)
2. auto_link=TRUE + custom claim → reset to FALSE (unsafe: no email_verified gate)
3. auto_link=TRUE + require_ev=TRUE + standard claim → unchanged (safe)
"""
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as conn:
await conn.execute(
text(
"CREATE TABLE oidc_providers ("
"id INTEGER PRIMARY KEY, "
"auto_link_existing_accounts BOOLEAN, "
"require_email_verified BOOLEAN, "
"email_claim TEXT"
")"
)
)
# Row 1: unsafe — require_ev=FALSE
await conn.execute(text("INSERT INTO oidc_providers VALUES (1, 1, 0, 'email')"))
# Row 2: unsafe — custom claim
await conn.execute(text("INSERT INTO oidc_providers VALUES (2, 1, 1, 'preferred_username')"))
# Row 3: safe — require_ev=TRUE + standard claim
await conn.execute(text("INSERT INTO oidc_providers VALUES (3, 1, 1, 'email')"))
async with conn.begin_nested():
await conn.execute(
text(
"UPDATE oidc_providers SET auto_link_existing_accounts = FALSE "
"WHERE auto_link_existing_accounts = TRUE "
"AND (require_email_verified = FALSE OR email_claim != 'email')"
)
)
result = await conn.execute(text("SELECT id, auto_link_existing_accounts FROM oidc_providers ORDER BY id"))
rows = {r[0]: r[1] for r in result.fetchall()}
await engine.dispose()
assert rows[1] == 0, "unsafe (require_ev=FALSE) row must be reset to FALSE"
assert rows[2] == 0, "unsafe (custom claim) row must be reset to FALSE"
assert rows[3] == 1, "safe row must remain TRUE"
@pytest.mark.asyncio
async def test_safe_execute_reraises_does_not_exist_without_column(self):
"""'does not exist' without 'column' in the message must NOT be swallowed.
This verifies that the narrowing from the broad 'does not exist' substring
to the compound RENAME-COLUMN-only guard works correctly. A missing-relation
error must propagate so the operator sees a startup failure rather than a
silent schema gap.
"""
from unittest.mock import AsyncMock, MagicMock
from sqlalchemy.exc import ProgrammingError
from backend.app.core.database import _safe_execute
# PostgreSQL error for a missing relation — contains "does not exist" but NOT "column"
fake_exc = ProgrammingError('relation "oidc_providers" does not exist', [], Exception())
nested_cm = MagicMock()
nested_cm.__aenter__ = AsyncMock(return_value=nested_cm)
nested_cm.execute = AsyncMock(side_effect=fake_exc)
nested_cm.__aexit__ = AsyncMock(return_value=False)
mock_conn = MagicMock()
mock_conn.begin_nested.return_value = nested_cm
mock_conn.execute = AsyncMock(side_effect=fake_exc)
# Must RAISE — "column" is absent so this is not RENAME COLUMN idempotency
with pytest.raises(ProgrammingError):
await _safe_execute(
mock_conn, "ALTER TABLE oidc_providers ADD COLUMN auto_link_existing_accounts BOOLEAN DEFAULT false"
)
@pytest.mark.asyncio
async def test_oidc_boolean_default_migrations_sqlite_defaults(self):
"""auto_link defaults to 0 (FALSE) and require_email_verified defaults to 1 (TRUE) on SQLite.
Verifies that the SQLite branch of the BOOLEAN DEFAULT dialect-branch uses
the correct integer literals so new rows get safe defaults without explicit values.
"""
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
from backend.app.core.database import _safe_execute
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as conn:
await conn.execute(text("CREATE TABLE oidc_providers (id INTEGER PRIMARY KEY, name TEXT)"))
await _safe_execute(
conn, "ALTER TABLE oidc_providers ADD COLUMN auto_link_existing_accounts BOOLEAN DEFAULT 0"
)
await _safe_execute(conn, "ALTER TABLE oidc_providers ADD COLUMN require_email_verified BOOLEAN DEFAULT 1")
await conn.execute(text("INSERT INTO oidc_providers (id, name) VALUES (1, 'test')"))
result = await conn.execute(
text("SELECT auto_link_existing_accounts, require_email_verified FROM oidc_providers WHERE id = 1")
)
row = result.fetchone()
await engine.dispose()
assert row[0] == 0, "auto_link_existing_accounts must default to 0 (FALSE) on SQLite"
assert row[1] == 1, "require_email_verified must default to 1 (TRUE) on SQLite"
@pytest.mark.asyncio
async def test_safe_execute_column_not_exists_only_swallowed_for_rename(self):
"""'column … does not exist' is swallowed only when the SQL is RENAME COLUMN.
The compound guard must NOT swallow the same error pattern when the SQL is
an ADD COLUMN statement — that would indicate schema corruption, not idempotency.
"""
from unittest.mock import AsyncMock, MagicMock
from sqlalchemy.exc import ProgrammingError
from backend.app.core.database import _safe_execute
fake_exc = ProgrammingError('column "auto_link_existing_accounts" does not exist', [], Exception())
nested_cm = MagicMock()
nested_cm.__aenter__ = AsyncMock(return_value=nested_cm)
nested_cm.execute = AsyncMock(side_effect=fake_exc)
nested_cm.__aexit__ = AsyncMock(return_value=False)
mock_conn = MagicMock()
mock_conn.begin_nested.return_value = nested_cm
mock_conn.execute = AsyncMock(side_effect=fake_exc)
# ADD COLUMN statement — must RAISE even though message contains "column" + "does not exist"
with pytest.raises(ProgrammingError):
await _safe_execute(
mock_conn, "ALTER TABLE oidc_providers ADD COLUMN auto_link_existing_accounts BOOLEAN DEFAULT false"
)
# RENAME COLUMN statement — must NOT raise (idempotency)
await _safe_execute(
mock_conn, "ALTER TABLE oidc_providers RENAME COLUMN auto_link_existing_accounts TO auto_link"
)
@pytest.mark.asyncio
async def test_normalize_printer_ids_sqlite_uses_plain_comparison(self):
"""SQLite path executes plain string comparison (no cast)."""
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
from backend.app.core.database import _migrate_normalize_printer_ids
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as conn:
await conn.execute(text("CREATE TABLE api_keys (id INTEGER PRIMARY KEY, printer_ids TEXT)"))
await conn.execute(text("INSERT INTO api_keys VALUES (1, '[]')"))
await conn.execute(text("INSERT INTO api_keys VALUES (2, '[1,2]')"))
with patch("backend.app.core.database.is_sqlite", return_value=True):
await _migrate_normalize_printer_ids(conn)
result = await conn.execute(text("SELECT id, printer_ids FROM api_keys ORDER BY id"))
rows = {r[0]: r[1] for r in result.fetchall()}
await engine.dispose()
assert rows[1] is None, "printer_ids='[]' must be normalised to NULL"
assert rows[2] == "[1,2]", "non-empty printer_ids must be unchanged"
@pytest.mark.asyncio
async def test_normalize_printer_ids_postgres_uses_text_cast(self):
"""PostgreSQL path casts printer_ids to text for comparison (works for json and jsonb)."""
from unittest.mock import AsyncMock, MagicMock
from backend.app.core.database import _migrate_normalize_printer_ids
nested_cm = MagicMock()
nested_cm.__aenter__ = AsyncMock(return_value=nested_cm)
nested_cm.__aexit__ = AsyncMock(return_value=False)
mock_conn = MagicMock()
mock_conn.begin_nested.return_value = nested_cm
mock_conn.execute = AsyncMock()
with patch("backend.app.core.database.is_sqlite", return_value=False):
await _migrate_normalize_printer_ids(mock_conn)
sql = mock_conn.execute.call_args[0][0].text
assert "::text = '[]'" in sql, f"Expected ::text cast in SQL, got: {sql}"
assert "printer_ids" in sql