mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-17 11:25:48 +02:00
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:
+136
-18
@@ -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
@@ -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"),
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user