fix(oidc): Allow auto_link_existing_accounts with custom email claims (Azure Entra ID) (#1142)

chore(i18n): extend parity gate to all locales with strict/info tiers
This commit is contained in:
Sn0rrii
2026-04-28 17:37:48 +02:00
committed by GitHub
parent 724bc92c22
commit 78408856cd
19 changed files with 628 additions and 87 deletions
+2
View File
@@ -48,6 +48,8 @@ All notable changes to Bambuddy will be documented in this file.
- **Per-request trace ID column on every log line, plumbed through HTTP access log + application logs + response headers** — Builds on the new uvicorn-access-log-into-bambuddy.log change below: the access line tells you *who* called an endpoint, but until now there was no way to tie that line to the application records emitted on the server side while handling that request. A new FastAPI middleware (`trace_id_middleware` in `main.py`, sourced from `backend.app.core.trace`) stamps each request with a fresh 8-char hex ID (or honours a sane inbound `X-Trace-Id` header for cross-system correlation), stores it in a `ContextVar` so any code in the request's call stack can read it, echoes it on the response as `X-Trace-Id`, and a new `TraceIDFilter` injects it into every `LogRecord` so the format string `[%(trace_id)s]` resolves to the right ID for the right request. ContextVars (rather than `request.state`) are the right plumbing here because asyncio copies the current context into every `asyncio.create_task`, so background work spawned from inside a request inherits the trace ID without explicit threading; the logging filter has no access to the FastAPI request object regardless. Records emitted outside any request scope (startup, MQTT callbacks, scheduler) get a stable `-` placeholder so the column stays visually aligned and missing values are obvious in `grep`. Inbound `X-Trace-Id` is hard-validated against a strict whitelist (`[A-Za-z0-9_-]+`, max 64 chars) before being honoured — a hostile or buggy caller cannot smuggle log-injection payloads (newlines, control chars, megabyte blobs) into `bambuddy.log` via the trace-ID column; values that fail the gate silently trigger a freshly minted server-side ID rather than failing the request. Middleware is decorated AFTER `auth_middleware` on purpose: Starlette stacks `@app.middleware` decorators LIFO so the last-decorated runs first inbound, making trace stamp the OUTERMOST layer — auth log lines and every record emitted on the way down to and back from the route handler all carry the same ID. Output now looks like `2026-04-26 09:51:39,152 INFO [uvicorn.access] [a4f3b1e7] 192.168.1.42:54812 - "POST /api/v1/printers/1/print/stop HTTP/1.1" 200` paired with the route handler's `2026-04-26 09:51:39,158 INFO [bambu_mqtt] [a4f3b1e7] [SERIAL] Sent stop print command` — one `grep a4f3b1e7` away from the full causality chain. 30 new tests across `tests/unit/test_trace.py` (placeholder when no request scope, filter copies ContextVar value onto records, ID propagates into spawned tasks via asyncio context copy, concurrent requests don't leak IDs into each other, generator produces unique hex IDs, hostile payloads rejected by validator, max-length boundary, dash/underscore variants accepted) plus `tests/integration/test_trace_middleware.py` (X-Trace-Id header echoed on response, body and header IDs match, each request gets a unique ID, generator format stays short hex, safe inbound IDs honoured, hostile inbound IDs replaced, overlong inbound IDs replaced, ContextVar reset cleanly after request). - **Per-request trace ID column on every log line, plumbed through HTTP access log + application logs + response headers** — Builds on the new uvicorn-access-log-into-bambuddy.log change below: the access line tells you *who* called an endpoint, but until now there was no way to tie that line to the application records emitted on the server side while handling that request. A new FastAPI middleware (`trace_id_middleware` in `main.py`, sourced from `backend.app.core.trace`) stamps each request with a fresh 8-char hex ID (or honours a sane inbound `X-Trace-Id` header for cross-system correlation), stores it in a `ContextVar` so any code in the request's call stack can read it, echoes it on the response as `X-Trace-Id`, and a new `TraceIDFilter` injects it into every `LogRecord` so the format string `[%(trace_id)s]` resolves to the right ID for the right request. ContextVars (rather than `request.state`) are the right plumbing here because asyncio copies the current context into every `asyncio.create_task`, so background work spawned from inside a request inherits the trace ID without explicit threading; the logging filter has no access to the FastAPI request object regardless. Records emitted outside any request scope (startup, MQTT callbacks, scheduler) get a stable `-` placeholder so the column stays visually aligned and missing values are obvious in `grep`. Inbound `X-Trace-Id` is hard-validated against a strict whitelist (`[A-Za-z0-9_-]+`, max 64 chars) before being honoured — a hostile or buggy caller cannot smuggle log-injection payloads (newlines, control chars, megabyte blobs) into `bambuddy.log` via the trace-ID column; values that fail the gate silently trigger a freshly minted server-side ID rather than failing the request. Middleware is decorated AFTER `auth_middleware` on purpose: Starlette stacks `@app.middleware` decorators LIFO so the last-decorated runs first inbound, making trace stamp the OUTERMOST layer — auth log lines and every record emitted on the way down to and back from the route handler all carry the same ID. Output now looks like `2026-04-26 09:51:39,152 INFO [uvicorn.access] [a4f3b1e7] 192.168.1.42:54812 - "POST /api/v1/printers/1/print/stop HTTP/1.1" 200` paired with the route handler's `2026-04-26 09:51:39,158 INFO [bambu_mqtt] [a4f3b1e7] [SERIAL] Sent stop print command` — one `grep a4f3b1e7` away from the full causality chain. 30 new tests across `tests/unit/test_trace.py` (placeholder when no request scope, filter copies ContextVar value onto records, ID propagates into spawned tasks via asyncio context copy, concurrent requests don't leak IDs into each other, generator produces unique hex IDs, hostile payloads rejected by validator, max-length boundary, dash/underscore variants accepted) plus `tests/integration/test_trace_middleware.py` (X-Trace-Id header echoed on response, body and header IDs match, each request gets a unique ID, generator format stays short hex, safe inbound IDs honoured, hostile inbound IDs replaced, overlong inbound IDs replaced, ContextVar reset cleanly after request).
### Fixed ### Fixed
- **OIDC `auto_link_existing_accounts` now works with custom email claims (Azure Entra ID)** ([#1088](https://github.com/maziggy/bambuddy/issues/1088)) — `auto_link_existing_accounts` was previously blocked unless both `email_claim='email'` and `require_email_verified=True`. This also rejected Azure Entra ID configurations using `preferred_username` or `upn` as the email claim — the recommended setup for that provider, which does not send `email_verified`. The guard now only blocks the genuinely unsafe combination (Fall B): `email_claim='email'` + `require_email_verified=False`. Custom-claim configurations (Fall C) never consult `email_verified` at all, so there is no verification-bypass risk on that path. All five enforcement layers (DB CHECK constraint, schema validators for create and update, route combined-state guard, DB migration for existing installations) have been updated consistently. **Security note:** custom claims are safe for auto-link only when the claim value is tenant-administered. If your IdP allows end users to self-assert the claim's value, do not enable auto-link. An in-app warning is shown in the OIDC provider form when this combination is configured.
- **OIDC settings form: "Require email verified" toggle no longer jumps layout when auto-link is enabled** — When `Auto-link existing accounts` was toggled on, the shorter description text caused the `Require email verified` toggle to reflow next to `Auto-link` in the flex container instead of staying on its own row. Both toggles now have `w-full` and always occupy a full row regardless of description length.
- **P1P print dispatch failed with `0500_4003 "can't parse print file"` when the printer was slow to acknowledge** ([#1150](https://github.com/maziggy/bambuddy/issues/1150), reported by @d3ni3) — On a P1P at firmware 01.10.00.00 the printer can take up to ~135 seconds to actually start parsing a freshly uploaded `.3mf` after the MQTT `project_file` command lands; FTP STOR returns 226 cleanly and the upload is intact, but `gcode_state` stays at `IDLE` and `subtask_id` doesn't advance until the printer's slow internal parse completes. Both dispatch watchdogs (`_verify_print_response` in `background_dispatch.py` and `_watchdog_print_start` in `print_scheduler.py`) interpreted the missed transition as a half-broken MQTT session — the original #887/#936 condition where telemetry kept arriving but our publishes were silently swallowed — and called `force_reconnect_stale_session` to wipe paho's QoS-1 queue and reconnect with a fresh client_id. That reconnect mid-parse is precisely what makes the P1P emit `0500_4003`: the new MQTT session interrupts the in-progress parse on the printer side and the printer reports the file as unparseable. The repro: send a print job, wait 15 seconds while the printer is still parsing, watch the watchdog force-reconnect, watch the printer fail with the parse error, retry — same loop. Sending the same file from BambuStudio worked because BambuStudio doesn't reconnect MQTT mid-parse. The fix uses the printer's `gcode_file` field as a definitive discriminator between #1150 (slow parse) and #887/#936 (half-broken session), since both look identical from telemetry alone: in both cases push_status keeps flowing, `state` stays unchanged, and `subtask_id` stays at the pre-dispatch value. The distinguishing signal: when the project_file command actually lands on the printer side, the printer's `gcode_file` field updates in push_status to reflect the newly-uploaded file; if the publish was silently swallowed (#887/#936), the field stays at whatever the printer was previously showing. Both watchdogs now capture `pre_gcode_file` alongside `pre_state` and `pre_subtask_id` from `printer_manager.get_status()` before sending the publish, then compare against the printer's current `gcode_file` after the watchdog times out. If the value changed → command landed → log a `#1150` warning explaining the skip and leave the MQTT session alone. If the value is unchanged → publish was silently swallowed → fall through to the original `force_reconnect_stale_session` call so the #887/#936/#1136 zombie-session recovery is preserved exactly. The user-facing dispatch still fails on timeout (correctly — the print didn't start within the timeout window so the job is marked failed), the queue item still reverts to `pending` so the scheduler can retry, and the next dispatch attempt proceeds against the same intact MQTT session that was about to start the print. Pairs with the 15s → 90s timeout bump that already shipped in commit 9d041868 (the original 15s timeout was a separate v0.2.3.2 limit). Caveat acknowledged in code comments: in a retry-same-file slow-parse scenario the printer's `gcode_file` looks identical before and after the publish lands, so the watchdog falls through to the original reconnect path and the user still sees `0500_4003` on that specific retry — accepted to avoid breaking the half-broken-session recovery, which is the more impactful regression of the two. 4 new unit tests covering both watchdogs: skip reconnect when `gcode_file` changed (the #1150 fix), reconnect when `gcode_file` is unchanged (the #936 protection preserved), skip reconnect when `pre_gcode_file=None` and current is non-None (printer just connected), reconnect when `pre_gcode_file` arg is omitted (backward-compat for callers we haven't updated). All 439 existing dispatch / scheduler / mqtt tests still pass unchanged. - **P1P print dispatch failed with `0500_4003 "can't parse print file"` when the printer was slow to acknowledge** ([#1150](https://github.com/maziggy/bambuddy/issues/1150), reported by @d3ni3) — On a P1P at firmware 01.10.00.00 the printer can take up to ~135 seconds to actually start parsing a freshly uploaded `.3mf` after the MQTT `project_file` command lands; FTP STOR returns 226 cleanly and the upload is intact, but `gcode_state` stays at `IDLE` and `subtask_id` doesn't advance until the printer's slow internal parse completes. Both dispatch watchdogs (`_verify_print_response` in `background_dispatch.py` and `_watchdog_print_start` in `print_scheduler.py`) interpreted the missed transition as a half-broken MQTT session — the original #887/#936 condition where telemetry kept arriving but our publishes were silently swallowed — and called `force_reconnect_stale_session` to wipe paho's QoS-1 queue and reconnect with a fresh client_id. That reconnect mid-parse is precisely what makes the P1P emit `0500_4003`: the new MQTT session interrupts the in-progress parse on the printer side and the printer reports the file as unparseable. The repro: send a print job, wait 15 seconds while the printer is still parsing, watch the watchdog force-reconnect, watch the printer fail with the parse error, retry — same loop. Sending the same file from BambuStudio worked because BambuStudio doesn't reconnect MQTT mid-parse. The fix uses the printer's `gcode_file` field as a definitive discriminator between #1150 (slow parse) and #887/#936 (half-broken session), since both look identical from telemetry alone: in both cases push_status keeps flowing, `state` stays unchanged, and `subtask_id` stays at the pre-dispatch value. The distinguishing signal: when the project_file command actually lands on the printer side, the printer's `gcode_file` field updates in push_status to reflect the newly-uploaded file; if the publish was silently swallowed (#887/#936), the field stays at whatever the printer was previously showing. Both watchdogs now capture `pre_gcode_file` alongside `pre_state` and `pre_subtask_id` from `printer_manager.get_status()` before sending the publish, then compare against the printer's current `gcode_file` after the watchdog times out. If the value changed → command landed → log a `#1150` warning explaining the skip and leave the MQTT session alone. If the value is unchanged → publish was silently swallowed → fall through to the original `force_reconnect_stale_session` call so the #887/#936/#1136 zombie-session recovery is preserved exactly. The user-facing dispatch still fails on timeout (correctly — the print didn't start within the timeout window so the job is marked failed), the queue item still reverts to `pending` so the scheduler can retry, and the next dispatch attempt proceeds against the same intact MQTT session that was about to start the print. Pairs with the 15s → 90s timeout bump that already shipped in commit 9d041868 (the original 15s timeout was a separate v0.2.3.2 limit). Caveat acknowledged in code comments: in a retry-same-file slow-parse scenario the printer's `gcode_file` looks identical before and after the publish lands, so the watchdog falls through to the original reconnect path and the user still sees `0500_4003` on that specific retry — accepted to avoid breaking the half-broken-session recovery, which is the more impactful regression of the two. 4 new unit tests covering both watchdogs: skip reconnect when `gcode_file` changed (the #1150 fix), reconnect when `gcode_file` is unchanged (the #936 protection preserved), skip reconnect when `pre_gcode_file=None` and current is non-None (printer just connected), reconnect when `pre_gcode_file` arg is omitted (backward-compat for callers we haven't updated). All 439 existing dispatch / scheduler / mqtt tests still pass unchanged.
- **3MF profile-driven slicing silently produced wrong-printer output (every 3MF slice fell back to the source's embedded printer regardless of the picked profile)** — Two stacked bugs in the slice pipeline. **(1) Pre-forward strip removed too much.** `_strip_3mf_embedded_settings` was scrubbing all four embedded `Metadata/*.config` files before forwarding the 3MF to the sidecar, on the theory that `--load-settings` would then take precedence cleanly. That theory was wrong: `Metadata/model_settings.config` carries the plate definitions the CLI needs to map `--slice N` to a real plate, and `slice_info.config` / `project_settings.config` supply baseline config the CLI's `StaticPrintConfigs` pass needs to even start. Stripping any of them caused the CLI to silently exit immediately after "Initializing StaticPrintConfigs" — exit code 0, no `result.json`, no stderr — which the sidecar treated as failure and Bambuddy then masked by falling back to `slice_without_profiles` using the un-stripped bytes (and the source's embedded printer). Net effect: every 3MF slice with profiles silently produced wrong-printer output. The strip is now gone from the slicer dispatch path entirely; original bytes go to the sidecar so `--load-settings` overrides only the specific fields the user changed (printer/process/filament) while the embedded plate / model definitions remain intact. **(2) Standard-tier preset stubs were missing the `type` field.** `_resolve_standard` in `preset_resolver.py` emitted `{"name": ..., "inherits": ..., "from": "system"}` for the bundled tier, but the CLI's preset parser also requires a `type` discriminator (`machine` / `process` / `filament`) on every loaded settings file — without it the CLI silently rejects with `rc=-5` ("input preset file is invalid"), which the same masking fallback then turned into another wrong-printer slice. New `_SLOT_TO_PROFILE_TYPE` constant maps each slot to its required type, and the stub now emits the right value per slot. **Tests**: integration test renamed from "strip removes all four configs" to `test_3mf_input_forwarded_unmodified_to_sidecar` — asserts every `Metadata/*.config` plus `3D/3dmodel.model` is preserved verbatim in the multipart body the sidecar receives. Preset-resolver test updated for the new stub shape; new `test_standard_emits_correct_type_per_slot` pins each (slot → type) pairing. Pairs with the orca-slicer-api fork's `bambuddy/profile-resolver` branch which now emits `details` on its `AppError` responses and captures CLI stdout/stderr in the failure path so future regressions of this shape produce a real error message instead of a silent fallback. - **3MF profile-driven slicing silently produced wrong-printer output (every 3MF slice fell back to the source's embedded printer regardless of the picked profile)** — Two stacked bugs in the slice pipeline. **(1) Pre-forward strip removed too much.** `_strip_3mf_embedded_settings` was scrubbing all four embedded `Metadata/*.config` files before forwarding the 3MF to the sidecar, on the theory that `--load-settings` would then take precedence cleanly. That theory was wrong: `Metadata/model_settings.config` carries the plate definitions the CLI needs to map `--slice N` to a real plate, and `slice_info.config` / `project_settings.config` supply baseline config the CLI's `StaticPrintConfigs` pass needs to even start. Stripping any of them caused the CLI to silently exit immediately after "Initializing StaticPrintConfigs" — exit code 0, no `result.json`, no stderr — which the sidecar treated as failure and Bambuddy then masked by falling back to `slice_without_profiles` using the un-stripped bytes (and the source's embedded printer). Net effect: every 3MF slice with profiles silently produced wrong-printer output. The strip is now gone from the slicer dispatch path entirely; original bytes go to the sidecar so `--load-settings` overrides only the specific fields the user changed (printer/process/filament) while the embedded plate / model definitions remain intact. **(2) Standard-tier preset stubs were missing the `type` field.** `_resolve_standard` in `preset_resolver.py` emitted `{"name": ..., "inherits": ..., "from": "system"}` for the bundled tier, but the CLI's preset parser also requires a `type` discriminator (`machine` / `process` / `filament`) on every loaded settings file — without it the CLI silently rejects with `rc=-5` ("input preset file is invalid"), which the same masking fallback then turned into another wrong-printer slice. New `_SLOT_TO_PROFILE_TYPE` constant maps each slot to its required type, and the stub now emits the right value per slot. **Tests**: integration test renamed from "strip removes all four configs" to `test_3mf_input_forwarded_unmodified_to_sidecar` — asserts every `Metadata/*.config` plus `3D/3dmodel.model` is preserved verbatim in the multipart body the sidecar receives. Preset-resolver test updated for the new stub shape; new `test_standard_emits_correct_type_per_slot` pins each (slot → type) pairing. Pairs with the orca-slicer-api fork's `bambuddy/profile-resolver` branch which now emits `details` on its `AppError` responses and captures CLI stdout/stderr in the failure path so future regressions of this shape produce a real error message instead of a silent fallback.
+10 -9
View File
@@ -1,9 +1,8 @@
# Updating Bambuddy # Updating Bambuddy
> **One-time note for 0.2.2.x → 0.2.3:** the in-app **Update** button does not > **0.2.3 note:** the in-app **Update** button is unreliable when upgrading from
> reliably perform this specific migration. Do this one upgrade from the > older releases. Use the commands below instead — they cover every supported
> command line using the steps below. Once you're on 0.2.3, the in-app Update > install path and are safe to run repeatedly.
> button works normally again for all future releases.
Pick the section that matches how Bambuddy was installed. Pick the section that matches how Bambuddy was installed.
@@ -75,7 +74,10 @@ These installs have no `.git` directory, so neither `update.sh` nor a plain
```bash ```bash
# 1. Back up your stateful data # 1. Back up your stateful data
Create and download a backup via Bambuddy Settings -> Backup -> Local Backup sudo systemctl stop bambuddy
sudo tar czf ~/bambuddy-backup.tgz -C /opt/bambuddy \
data bambuddy.db bambuddy.db-shm bambuddy.db-wal \
virtual_printer archive projects icons .env 2>/dev/null || true
# 2. Remove the old install and reinstall via install.sh # 2. Remove the old install and reinstall via install.sh
sudo rm -rf /opt/bambuddy sudo rm -rf /opt/bambuddy
@@ -83,10 +85,9 @@ curl -fsSL https://raw.githubusercontent.com/maziggy/bambuddy/main/install/insta
-o /tmp/install.sh && sudo bash /tmp/install.sh --path /opt/bambuddy -o /tmp/install.sh && sudo bash /tmp/install.sh --path /opt/bambuddy
# 3. Restore your data # 3. Restore your data
Restore your backup via Bambuddy -> Settings -> Backup -> Local Backup sudo systemctl stop bambuddy
sudo tar xzf ~/bambuddy-backup.tgz -C /opt/bambuddy
# 4. Restart Bambuddy sudo systemctl start bambuddy
sudo systemctl restart bambuddy
``` ```
--- ---
+6 -7
View File
@@ -389,15 +389,14 @@ def _is_valid_email_shaped(value: str | None) -> bool:
def _enforce_auto_link_safety(provider: OIDCProvider) -> None: def _enforce_auto_link_safety(provider: OIDCProvider) -> None:
"""Raise HTTP 422 if auto_link_existing_accounts is on without safe email settings. """Raise HTTP 422 if auto_link_existing_accounts is on with an unsafe combined state.
SEC-1/SEC-6: auto_link is only safe when require_email_verified=True AND SEC-1: only Fall B (email_claim='email' + require_email_verified=False) is unsafe —
email_claim='email'. Called after ORM construction (create) and after the an attacker-controlled IdP could present an unverified email that matches a local account.
setattr loop (update) so partial-update bypasses are also caught. Fall C (custom claim) never performs an email_verified check, so auto_link is safe there.
Called after ORM construction (create) and after the setattr loop (update).
""" """
if provider.auto_link_existing_accounts and ( if provider.auto_link_existing_accounts and provider.email_claim == "email" and not provider.require_email_verified:
not provider.require_email_verified or provider.email_claim != "email"
):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=AUTO_LINK_REQUIREMENTS_ERROR, detail=AUTO_LINK_REQUIREMENTS_ERROR,
+104 -11
View File
@@ -276,6 +276,93 @@ async def _migrate_normalize_printer_ids(conn) -> None:
await conn.execute(text("UPDATE api_keys SET printer_ids = NULL WHERE printer_ids::text = '[]'")) await conn.execute(text("UPDATE api_keys SET printer_ids = NULL WHERE printer_ids::text = '[]'"))
async def _migrate_update_auto_link_constraint(conn) -> None:
"""Update the auto_link CHECK constraint to allow Fall C (custom email claim).
Old formula: auto_link = FALSE OR (require_ev = TRUE AND email_claim = 'email')
New formula: auto_link = FALSE OR email_claim != 'email' OR require_ev = TRUE
Only Fall B (email_claim='email' + require_ev=False) remains blocked.
Fall C (custom claim, e.g. Azure preferred_username/upn) is now allowed.
PostgreSQL: DROP CONSTRAINT IF EXISTS + ADD new formula via _safe_execute (idempotent).
SQLite: table recreation when old formula is detected in sqlite_master (idempotent).
"""
from sqlalchemy import text
_NEW_FORMULA = "auto_link_existing_accounts = FALSE OR email_claim != 'email' OR require_email_verified = TRUE"
_CONSTRAINT_NAME = "ck_auto_link_requires_verified_email_claim"
if not is_sqlite():
await _safe_execute(conn, f"ALTER TABLE oidc_providers DROP CONSTRAINT IF EXISTS {_CONSTRAINT_NAME}")
await _safe_execute(
conn,
f"ALTER TABLE oidc_providers ADD CONSTRAINT {_CONSTRAINT_NAME} CHECK ({_NEW_FORMULA})",
)
else:
row = (
await conn.execute(text("SELECT sql FROM sqlite_master WHERE type='table' AND name='oidc_providers'"))
).fetchone()
# Only recreate if the old (more restrictive) formula is still present.
# Fresh installs created with the new __table_args__ already have the correct formula.
# Installs without any constraint (pre-SEC-1 upgrades) are skipped — app-level guards suffice.
if row and "require_email_verified = TRUE AND email_claim = 'email'" in row[0]:
try:
async with conn.begin_nested():
await conn.execute(text("DROP TABLE IF EXISTS oidc_providers_v2"))
await conn.execute(
text(
"CREATE TABLE oidc_providers_v2 ("
"id INTEGER NOT NULL, "
"name VARCHAR(100) NOT NULL, "
"issuer_url VARCHAR(500) NOT NULL, "
"client_id VARCHAR(255) NOT NULL, "
"client_secret VARCHAR(512) NOT NULL, "
"scopes VARCHAR(500), "
"is_enabled BOOLEAN, "
"auto_create_users BOOLEAN, "
"auto_link_existing_accounts BOOLEAN DEFAULT 0, "
"email_claim VARCHAR(64) DEFAULT 'email', "
"require_email_verified BOOLEAN DEFAULT 1, "
"icon_url TEXT, "
"created_at DATETIME DEFAULT CURRENT_TIMESTAMP, "
"updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, "
"PRIMARY KEY (id), "
f"UNIQUE (name), "
f"CONSTRAINT {_CONSTRAINT_NAME} CHECK ({_NEW_FORMULA})"
")"
)
)
await conn.execute(
text(
"INSERT INTO oidc_providers_v2 "
"(id, name, issuer_url, client_id, client_secret, scopes, is_enabled, "
"auto_create_users, auto_link_existing_accounts, email_claim, "
"require_email_verified, icon_url, created_at, updated_at) "
"SELECT id, name, issuer_url, client_id, client_secret, scopes, is_enabled, "
"auto_create_users, auto_link_existing_accounts, email_claim, "
"require_email_verified, icon_url, created_at, updated_at "
"FROM oidc_providers"
)
)
original = (await conn.execute(text("SELECT count(*) FROM oidc_providers"))).scalar_one()
copied = (await conn.execute(text("SELECT count(*) FROM oidc_providers_v2"))).scalar_one()
if copied != original:
raise RuntimeError(
f"auto_link constraint migration: row count mismatch after copy "
f"({original} in source, {copied} in copy)"
)
await conn.execute(text("DROP TABLE oidc_providers"))
await conn.execute(text("ALTER TABLE oidc_providers_v2 RENAME TO oidc_providers"))
except Exception as exc:
logger.error(
"auto_link constraint update (SQLite table recreation) FAILED: %s",
exc,
exc_info=True,
)
raise
async def run_migrations(conn): async def run_migrations(conn):
"""Run all schema migrations and data backfills on startup. """Run all schema migrations and data backfills on startup.
@@ -1569,20 +1656,19 @@ async def run_migrations(conn):
await _safe_execute(conn, "ALTER TABLE oidc_providers ADD COLUMN require_email_verified BOOLEAN DEFAULT 1") await _safe_execute(conn, "ALTER TABLE oidc_providers ADD COLUMN require_email_verified BOOLEAN DEFAULT 1")
else: else:
await _safe_execute(conn, "ALTER TABLE oidc_providers ADD COLUMN require_email_verified BOOLEAN DEFAULT true") 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. # SEC-1 backfill: reset auto_link only for Fall B (email_claim='email' + require_email_verified=False).
# Runs BEFORE the CHECK constraint below so existing installs that have # Fall C (custom claim) is now allowed to use auto_link — do NOT reset those rows.
# auto_link=TRUE + unsafe email settings self-heal rather than failing when # Runs BEFORE the CHECK constraint below so Fall B rows self-heal rather than failing
# PostgreSQL validates ADD CONSTRAINT against existing rows ("check constraint # PostgreSQL's "check constraint is violated by some row" on ADD CONSTRAINT.
# is violated by some row"). On fresh installs the column defaults guarantee # On fresh installs the column defaults guarantee this UPDATE matches zero rows.
# this UPDATE matches zero rows. TRUE/FALSE literals are accepted by both # TRUE/FALSE literals are accepted by both SQLite (≥ 3.23) and PostgreSQL — no dialect branch needed.
# SQLite (≥ 3.23) and PostgreSQL — no dialect branch needed.
try: try:
async with conn.begin_nested(): async with conn.begin_nested():
await conn.execute( await conn.execute(
text( text(
"UPDATE oidc_providers SET auto_link_existing_accounts = FALSE " "UPDATE oidc_providers SET auto_link_existing_accounts = FALSE "
"WHERE auto_link_existing_accounts = TRUE " "WHERE auto_link_existing_accounts = TRUE "
"AND (require_email_verified = FALSE OR email_claim != 'email')" "AND email_claim = 'email' AND require_email_verified = FALSE"
) )
) )
except Exception as exc: except Exception as exc:
@@ -1594,16 +1680,16 @@ async def run_migrations(conn):
) )
raise raise
# SEC-1/SEC-6: Add DB-level CHECK constraint for existing PostgreSQL installs. # SEC-1: Add DB-level CHECK constraint for existing PostgreSQL installs.
# SQLite does not support ALTER TABLE ADD CONSTRAINT — handled by __table_args__ at creation. # 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. # Runs AFTER the backfill so Fall B rows don't fail constraint validation.
if not is_sqlite(): if not is_sqlite():
try: try:
async with conn.begin_nested(): async with conn.begin_nested():
await conn.execute( await conn.execute(
text( text(
"ALTER TABLE oidc_providers ADD CONSTRAINT ck_auto_link_requires_verified_email_claim " "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'))" "CHECK (auto_link_existing_accounts = FALSE OR email_claim != 'email' OR require_email_verified = TRUE)"
) )
) )
except (OperationalError, ProgrammingError) as exc: except (OperationalError, ProgrammingError) as exc:
@@ -1616,6 +1702,13 @@ async def run_migrations(conn):
) )
raise raise
# Migration: Update auto_link CHECK constraint formula (existing installs).
# Existing PostgreSQL installs that ran the ADD CONSTRAINT above with the old formula
# (or a previous version of this code) need an explicit DROP + ADD to update it.
# For SQLite, the table is recreated with the new constraint formula if the old formula
# is still present in sqlite_master (SQLite cannot ALTER TABLE DROP/ADD CONSTRAINT).
await _migrate_update_auto_link_constraint(conn)
# Migration: Add password_changed_at to users (M-R7-B) # Migration: Add password_changed_at to users (M-R7-B)
# Tracks the last time a user's password was changed/reset. JWTs whose iat # Tracks the last time a user's password was changed/reset. JWTs whose iat
# predates this timestamp are rejected in all six auth validation paths. # predates this timestamp are rejected in all six auth validation paths.
+4 -4
View File
@@ -22,11 +22,11 @@ class OIDCProvider(Base):
__tablename__ = "oidc_providers" __tablename__ = "oidc_providers"
__table_args__ = ( __table_args__ = (
# DB-level enforcement of SEC-1/SEC-6: auto_link is only safe when # DB-level enforcement of SEC-1: blocks only Fall B (email_claim='email' + require_ev=False).
# require_email_verified=True AND email_claim='email'. Enforced on new # Fall C (custom claim) is safe — no email_verified gate on that path.
# installations; existing tables get this via the PostgreSQL-only migration. # Enforced on new installations; existing tables updated via _migrate_update_auto_link_constraint.
CheckConstraint( CheckConstraint(
"auto_link_existing_accounts = FALSE OR (require_email_verified = TRUE AND email_claim = 'email')", "auto_link_existing_accounts = FALSE OR email_claim != 'email' OR require_email_verified = TRUE",
name="ck_auto_link_requires_verified_email_claim", name="ck_auto_link_requires_verified_email_claim",
), ),
) )
+12 -9
View File
@@ -297,7 +297,7 @@ class AdminDisable2FARequest(BaseModel):
AUTO_LINK_REQUIREMENTS_ERROR = ( AUTO_LINK_REQUIREMENTS_ERROR = (
"auto_link_existing_accounts requires require_email_verified=True and email_claim='email'" "auto_link_existing_accounts requires require_email_verified=True when email_claim='email'"
) )
@@ -398,12 +398,12 @@ class OIDCProviderCreate(BaseModel):
def validate_icon_url(cls, v: str | None) -> str | None: def validate_icon_url(cls, v: str | None) -> str | None:
return _validate_icon_url(v) return _validate_icon_url(v)
# SEC-1 + SEC-6: auto_link requires both require_email_verified=True AND email_claim="email". # SEC-1: auto_link with email_claim='email' requires require_email_verified=True.
# Fall B (require_email_verified=False) accepts absent email_verified → account-takeover risk. # Fall B (require_email_verified=False + email_claim='email') accepts absent email_verified → account-takeover risk.
# Fall C (custom claim) skips email_verified entirely → same risk. # Fall C (custom claim != 'email') is safe: no email_verified gate on that path regardless of require_email_verified.
@model_validator(mode="after") @model_validator(mode="after")
def check_auto_link_requires_verified(self) -> "OIDCProviderCreate": 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"): if self.auto_link_existing_accounts and self.email_claim == "email" and not self.require_email_verified:
raise ValueError(AUTO_LINK_REQUIREMENTS_ERROR) raise ValueError(AUTO_LINK_REQUIREMENTS_ERROR)
return self return self
@@ -444,13 +444,16 @@ class OIDCProviderUpdate(BaseModel):
def validate_icon_url(cls, v: str | None) -> str | None: def validate_icon_url(cls, v: str | None) -> str | None:
return _validate_icon_url(v) return _validate_icon_url(v)
# SEC-1 + SEC-6 (schema-level): blocks only when both conflicting fields arrive # SEC-1 (schema-level): blocks only when auto_link=True + email_claim='email' + require_email_verified=False
# in the same request. Partial updates spanning two requests are caught by the # arrive in the same request. email_claim=None means the request leaves it unchanged (still 'email' by default),
# so that is also treated as 'email'. Partial updates spanning two requests are caught by the
# Combined-State-Guard in the route handler after the setattr loop. # Combined-State-Guard in the route handler after the setattr loop.
@model_validator(mode="after") @model_validator(mode="after")
def check_auto_link_requires_verified(self) -> "OIDCProviderUpdate": def check_auto_link_requires_verified(self) -> "OIDCProviderUpdate":
if self.auto_link_existing_accounts is True and ( if (
self.require_email_verified is False or (self.email_claim is not None and self.email_claim != "email") self.auto_link_existing_accounts is True
and self.require_email_verified is False
and (self.email_claim is None or self.email_claim == "email")
): ):
raise ValueError(AUTO_LINK_REQUIREMENTS_ERROR) raise ValueError(AUTO_LINK_REQUIREMENTS_ERROR)
return self return self
+213 -13
View File
@@ -3522,8 +3522,12 @@ class TestOIDCEmailClaimResolution:
@pytest.mark.asyncio @pytest.mark.asyncio
@pytest.mark.integration @pytest.mark.integration
async def test_auto_link_blocked_with_custom_claim_create(self, async_client: AsyncClient): async def test_auto_link_allowed_with_custom_claim_create(self, async_client: AsyncClient):
"""SEC-6: auto_link + email_claim!='email' must be rejected at schema level on CREATE (422).""" """Fall C: auto_link + email_claim!='email' must be accepted on CREATE (201).
Custom claims (e.g. Azure preferred_username/upn) never perform an email_verified
check, so auto_link is safe regardless of require_email_verified.
"""
admin_token = await _setup_and_login(async_client, "sec6c_adm", "Sec6CAdm123!") admin_token = await _setup_and_login(async_client, "sec6c_adm", "Sec6CAdm123!")
resp = await async_client.post( resp = await async_client.post(
"/api/v1/auth/oidc/providers", "/api/v1/auth/oidc/providers",
@@ -3538,12 +3542,17 @@ class TestOIDCEmailClaimResolution:
}, },
headers={"Authorization": f"Bearer {admin_token}"}, headers={"Authorization": f"Bearer {admin_token}"},
) )
assert resp.status_code == 422 assert resp.status_code == 201
assert resp.json()["auto_link_existing_accounts"] is True
assert resp.json()["email_claim"] == "upn"
@pytest.mark.asyncio @pytest.mark.asyncio
@pytest.mark.integration @pytest.mark.integration
async def test_auto_link_blocked_with_custom_claim_update(self, async_client: AsyncClient): async def test_auto_link_allowed_with_custom_claim_update(self, async_client: AsyncClient):
"""SEC-6: auto_link=True + email_claim='upn' in same UPDATE request → 422 (T4).""" """Fall C: auto_link=True + email_claim='upn' in same UPDATE request → 200.
Custom claims never perform an email_verified check, so auto_link is safe.
"""
admin_token = await _setup_and_login(async_client, "sec6u_adm", "Sec6UAdm123!") admin_token = await _setup_and_login(async_client, "sec6u_adm", "Sec6UAdm123!")
create_resp = await async_client.post( create_resp = await async_client.post(
"/api/v1/auth/oidc/providers", "/api/v1/auth/oidc/providers",
@@ -3563,7 +3572,9 @@ class TestOIDCEmailClaimResolution:
json={"auto_link_existing_accounts": True, "email_claim": "upn"}, json={"auto_link_existing_accounts": True, "email_claim": "upn"},
headers={"Authorization": f"Bearer {admin_token}"}, headers={"Authorization": f"Bearer {admin_token}"},
) )
assert resp.status_code == 422 assert resp.status_code == 200
assert resp.json()["auto_link_existing_accounts"] is True
assert resp.json()["email_claim"] == "upn"
# ── Combined-State-Guard (partial updates across two requests) ───────────── # ── Combined-State-Guard (partial updates across two requests) ─────────────
@@ -3602,8 +3613,8 @@ class TestOIDCEmailClaimResolution:
@pytest.mark.asyncio @pytest.mark.asyncio
@pytest.mark.integration @pytest.mark.integration
async def test_partial_update_guard_email_claim(self, async_client: AsyncClient): async def test_partial_update_custom_claim_then_auto_link_allowed(self, async_client: AsyncClient):
"""SEC-6 Combined-State-Guard: email_claim='upn' then auto_link=True → 422 (T1 email_claim path).""" """Fall C: email_claim='upn' first, then auto_link=True → both 200 (custom claim is safe)."""
admin_token = await _setup_and_login(async_client, "pg_ec_adm", "PgEc123!") admin_token = await _setup_and_login(async_client, "pg_ec_adm", "PgEc123!")
create_resp = await async_client.post( create_resp = await async_client.post(
"/api/v1/auth/oidc/providers", "/api/v1/auth/oidc/providers",
@@ -3631,7 +3642,44 @@ class TestOIDCEmailClaimResolution:
json={"auto_link_existing_accounts": True}, json={"auto_link_existing_accounts": True},
headers={"Authorization": f"Bearer {admin_token}"}, headers={"Authorization": f"Bearer {admin_token}"},
) )
assert upd2.status_code == 422 assert upd2.status_code == 200
assert upd2.json()["auto_link_existing_accounts"] is True
assert upd2.json()["email_claim"] == "upn"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_partial_update_auto_link_then_custom_claim_allowed(self, async_client: AsyncClient):
"""Fall C: auto_link=True first (email_claim='email', safe), then email_claim='upn' → both 200."""
admin_token = await _setup_and_login(async_client, "pg_al_ec_adm", "PgAlEc123!")
create_resp = await async_client.post(
"/api/v1/auth/oidc/providers",
json={
"name": "PG-AutoLink-Claim-Test",
"issuer_url": "https://pg-al-ec.test",
"client_id": "pg-al-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={"auto_link_existing_accounts": True},
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={"email_claim": "preferred_username"},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert upd2.status_code == 200
assert upd2.json()["auto_link_existing_accounts"] is True
assert upd2.json()["email_claim"] == "preferred_username"
@pytest.mark.asyncio @pytest.mark.asyncio
@pytest.mark.integration @pytest.mark.integration
@@ -4009,7 +4057,11 @@ class TestOIDCEmailResolutionExtra:
@pytest.mark.asyncio @pytest.mark.asyncio
@pytest.mark.integration @pytest.mark.integration
async def test_combined_state_guard_email_claim_inverse_order(self, async_client: AsyncClient): 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.""" """Fall C: auto_link=True first, then switch email_claim to custom → both 200 (now allowed).
Custom claims never perform an email_verified check, so switching to a custom claim
while auto_link is on transitions from Fall A to Fall C — both are safe.
"""
admin_token = await _setup_and_login(async_client, "inv_ec_adm", "InvEc123!") admin_token = await _setup_and_login(async_client, "inv_ec_adm", "InvEc123!")
create_resp = await async_client.post( create_resp = await async_client.post(
"/api/v1/auth/oidc/providers", "/api/v1/auth/oidc/providers",
@@ -4025,7 +4077,7 @@ class TestOIDCEmailResolutionExtra:
assert create_resp.status_code == 201 assert create_resp.status_code == 201
provider_id = create_resp.json()["id"] provider_id = create_resp.json()["id"]
# First: enable auto_link (safe — email_claim="email", require_ev=True) # First: enable auto_link (Fall A — email_claim='email', require_ev=True)
upd1 = await async_client.put( upd1 = await async_client.put(
f"/api/v1/auth/oidc/providers/{provider_id}", f"/api/v1/auth/oidc/providers/{provider_id}",
json={"auto_link_existing_accounts": True}, json={"auto_link_existing_accounts": True},
@@ -4033,10 +4085,158 @@ class TestOIDCEmailResolutionExtra:
) )
assert upd1.status_code == 200 assert upd1.status_code == 200
# Second: switch email_claim to custom while auto_link is on → unsafe combined state # Second: switch to custom claim → Fall C, still safe
upd2 = await async_client.put( upd2 = await async_client.put(
f"/api/v1/auth/oidc/providers/{provider_id}", f"/api/v1/auth/oidc/providers/{provider_id}",
json={"email_claim": "preferred_username"}, json={"email_claim": "preferred_username"},
headers={"Authorization": f"Bearer {admin_token}"}, headers={"Authorization": f"Bearer {admin_token}"},
) )
assert upd2.status_code == 422 assert upd2.status_code == 200
assert upd2.json()["auto_link_existing_accounts"] is True
assert upd2.json()["email_claim"] == "preferred_username"
# ===========================================================================
# E2E: Fall C (custom email claim) auto-link actually links existing user
# ===========================================================================
class TestOIDCFallCAutoLinkE2E:
"""OIDC callback with email_claim='preferred_username' (Fall C / Azure Entra ID)
must auto-link an existing local user when auto_link_existing_accounts=True.
This test exercises _resolve_provider_email Fall C and the auto-link path in
oidc_callback — a regression in either would silently drop the link without
being caught by the configuration-layer tests.
"""
@pytest.mark.asyncio
@pytest.mark.integration
async def test_fall_c_auto_link_links_existing_user_via_callback(
self, async_client: AsyncClient, db_session: AsyncSession
):
from unittest.mock import AsyncMock, MagicMock, patch
from sqlalchemy import select as sa_select
from backend.app.core.auth import get_password_hash
from backend.app.models.oidc_provider import OIDCProvider, UserOIDCLink
issuer = "https://entra.fallc.example.com"
nonce = secrets.token_urlsafe(32)
code_verifier = secrets.token_urlsafe(48)
# ── 1. Local user that should be linked ──────────────────────────────
alice = User(
username="fallc_alice",
email="alice.fallc@example.com",
password_hash=get_password_hash(secrets.token_urlsafe(16)),
role="user",
is_active=True,
)
db_session.add(alice)
await db_session.flush()
# ── 2. Provider: Fall C config (preferred_username, no email_verified) ─
provider = OIDCProvider(
name="AzureEntraFallC",
issuer_url=issuer,
client_id="azure-client",
_client_secret_enc="azure-secret",
scopes="openid profile",
is_enabled=True,
auto_link_existing_accounts=True,
auto_create_users=False,
email_claim="preferred_username",
require_email_verified=False,
)
db_session.add(provider)
await db_session.flush()
# ── 3. OIDC state token ───────────────────────────────────────────────
state = secrets.token_urlsafe(32)
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=10),
)
)
await db_session.commit()
# ── 4. Mock HTTP + JWT ────────────────────────────────────────────────
fake_discovery = {
"issuer": issuer,
"token_endpoint": f"{issuer}/token",
"jwks_uri": f"{issuer}/.well-known/jwks.json",
}
fake_token = {"access_token": "acc_tok", "id_token": "fake.id.token"}
# Fall C: preferred_username carries the email; no email_verified key at all
fake_claims = {
"sub": "azure-sub-alice",
"preferred_username": "alice.fallc@example.com",
"iss": issuer,
"aud": "azure-client",
"nonce": nonce,
"exp": 9_999_999_999,
}
disc_resp = AsyncMock()
disc_resp.raise_for_status = MagicMock()
disc_resp.json = MagicMock(return_value=fake_discovery)
token_resp = AsyncMock()
token_resp.json = MagicMock(return_value=fake_token)
jwks_resp = AsyncMock()
jwks_resp.raise_for_status = MagicMock()
jwks_resp.json = MagicMock(return_value={})
mock_http = AsyncMock()
mock_http.get = AsyncMock(side_effect=[disc_resp, jwks_resp])
mock_http.post = AsyncMock(return_value=token_resp)
mock_signing_key = MagicMock()
mock_signing_key.key = "fake_key"
with (
patch("backend.app.api.routes.mfa.httpx.AsyncClient") as mock_httpx_cls,
patch("backend.app.api.routes.mfa.jwt.decode", return_value=fake_claims),
patch("backend.app.api.routes.mfa.PyJWKClient") as mock_jwks_cls,
):
mock_httpx_cls.return_value.__aenter__ = AsyncMock(return_value=mock_http)
mock_httpx_cls.return_value.__aexit__ = AsyncMock(return_value=False)
mock_jwks_cls.return_value.get_signing_key_from_jwt.return_value = mock_signing_key
callback_resp = await async_client.get(
f"/api/v1/auth/oidc/callback?code=fake_code&state={state}",
follow_redirects=False,
)
assert callback_resp.status_code == 302, callback_resp.text
location = callback_resp.headers.get("location", "")
assert "oidc_token=" in location, f"Expected oidc_token in redirect, got: {location}"
# ── 5. Exchange token → full JWT ──────────────────────────────────────
oidc_exchange_token = location.split("oidc_token=")[1].split("&")[0].split("#")[-1]
exchange_resp = await async_client.post(
"/api/v1/auth/oidc/exchange",
json={"oidc_token": oidc_exchange_token},
)
assert exchange_resp.status_code == 200
assert exchange_resp.json()["user"]["username"] == "fallc_alice"
# ── 6. Verify UserOIDCLink was created in DB ──────────────────────────
async with db_session as s:
result = await s.execute(
sa_select(UserOIDCLink).where(
UserOIDCLink.user_id == alice.id,
UserOIDCLink.provider_id == provider.id,
)
)
link = result.scalar_one_or_none()
assert link is not None, "UserOIDCLink must have been created by auto-link"
assert link.provider_user_id == "azure-sub-alice"
+235 -17
View File
@@ -316,7 +316,12 @@ class TestSafeExecutePattern:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_check_constraint_false_true_on_sqlite(self): async def test_check_constraint_false_true_on_sqlite(self):
"""CheckConstraint with FALSE/TRUE literals is enforced on SQLite (3.23+).""" """New constraint formula is enforced on SQLite (3.23+).
New: auto_link = FALSE OR email_claim != 'email' OR require_ev = TRUE
Blocks Fall B (auto_link=1 + email_claim='email' + require_ev=0).
Allows Fall A (email_claim='email' + require_ev=1) and Fall C (custom claim).
"""
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import create_async_engine from sqlalchemy.ext.asyncio import create_async_engine
@@ -330,29 +335,32 @@ class TestSafeExecutePattern:
auto_link BOOLEAN, auto_link BOOLEAN,
require_ev BOOLEAN, require_ev BOOLEAN,
email_claim TEXT, email_claim TEXT,
CHECK (auto_link = FALSE OR (require_ev = TRUE AND email_claim = 'email')) CHECK (auto_link = FALSE OR email_claim != 'email' OR require_ev = TRUE)
) )
""") """)
) )
# Valid: auto_link=0 (FALSE) # Valid: auto_link=0 (FALSE) — any combo allowed
await conn.execute(text("INSERT INTO ck_test VALUES (1, 0, 0, 'upn')")) await conn.execute(text("INSERT INTO ck_test VALUES (1, 0, 0, 'upn')"))
# Valid: auto_link=1, require_ev=1, email_claim='email' # Valid: Fall A — auto_link=1, require_ev=1, email_claim='email'
await conn.execute(text("INSERT INTO ck_test VALUES (2, 1, 1, 'email')")) await conn.execute(text("INSERT INTO ck_test VALUES (2, 1, 1, 'email')"))
# Valid: Fall C — auto_link=1, email_claim='upn' (require_ev irrelevant)
await conn.execute(text("INSERT INTO ck_test VALUES (3, 1, 0, 'upn')"))
await conn.execute(text("INSERT INTO ck_test VALUES (4, 1, 1, 'upn')"))
async with engine.begin() as conn: async with engine.begin() as conn:
# Invalid: auto_link=1 but conditions not met # Invalid: Fall B — auto_link=1 + email_claim='email' + require_ev=0
with pytest.raises(IntegrityError): with pytest.raises(IntegrityError):
await conn.execute(text("INSERT INTO ck_test VALUES (3, 1, 0, 'email')")) await conn.execute(text("INSERT INTO ck_test VALUES (5, 1, 0, 'email')"))
await engine.dispose() await engine.dispose()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_auto_link_sec1_backfill_resets_unsafe_rows(self): async def test_auto_link_sec1_backfill_resets_unsafe_rows(self):
"""SEC-1 backfill resets auto_link=TRUE on rows with unsafe combined state. """SEC-1 backfill resets auto_link=TRUE only for Fall B (email_claim='email' + require_ev=FALSE).
Three cases: Three cases:
1. auto_link=TRUE + require_ev=FALSE → reset to FALSE (unsafe: permissive mode) 1. auto_link=TRUE + email_claim='email' + require_ev=FALSE → reset to FALSE (Fall B, unsafe)
2. auto_link=TRUE + custom claim → reset to FALSE (unsafe: no email_verified gate) 2. auto_link=TRUE + custom claim + require_ev=TRUE → unchanged (Fall C, now allowed)
3. auto_link=TRUE + require_ev=TRUE + standard claim → unchanged (safe) 3. auto_link=TRUE + email_claim='email' + require_ev=TRUE → unchanged (Fall A, safe)
""" """
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine from sqlalchemy.ext.asyncio import create_async_engine
@@ -369,11 +377,11 @@ class TestSafeExecutePattern:
")" ")"
) )
) )
# Row 1: unsafe — require_ev=FALSE # Row 1: Fall B — email_claim='email' + require_ev=FALSE → must be reset
await conn.execute(text("INSERT INTO oidc_providers VALUES (1, 1, 0, 'email')")) await conn.execute(text("INSERT INTO oidc_providers VALUES (1, 1, 0, 'email')"))
# Row 2: unsafe — custom claim # Row 2: Fall C — custom claim → must NOT be reset (now allowed)
await conn.execute(text("INSERT INTO oidc_providers VALUES (2, 1, 1, 'preferred_username')")) await conn.execute(text("INSERT INTO oidc_providers VALUES (2, 1, 1, 'preferred_username')"))
# Row 3: safe — require_ev=TRUE + standard claim # Row 3: Fall A — email_claim='email' + require_ev=TRUE → must NOT be reset (always safe)
await conn.execute(text("INSERT INTO oidc_providers VALUES (3, 1, 1, 'email')")) await conn.execute(text("INSERT INTO oidc_providers VALUES (3, 1, 1, 'email')"))
async with conn.begin_nested(): async with conn.begin_nested():
@@ -381,7 +389,7 @@ class TestSafeExecutePattern:
text( text(
"UPDATE oidc_providers SET auto_link_existing_accounts = FALSE " "UPDATE oidc_providers SET auto_link_existing_accounts = FALSE "
"WHERE auto_link_existing_accounts = TRUE " "WHERE auto_link_existing_accounts = TRUE "
"AND (require_email_verified = FALSE OR email_claim != 'email')" "AND email_claim = 'email' AND require_email_verified = FALSE"
) )
) )
@@ -389,9 +397,9 @@ class TestSafeExecutePattern:
rows = {r[0]: r[1] for r in result.fetchall()} rows = {r[0]: r[1] for r in result.fetchall()}
await engine.dispose() await engine.dispose()
assert rows[1] == 0, "unsafe (require_ev=FALSE) row must be reset to FALSE" assert rows[1] == 0, "Fall B (require_ev=FALSE) must be reset to FALSE"
assert rows[2] == 0, "unsafe (custom claim) row must be reset to FALSE" assert rows[2] == 1, "Fall C (custom claim) must remain TRUE"
assert rows[3] == 1, "safe row must remain TRUE" assert rows[3] == 1, "Fall A (require_ev=TRUE) must remain TRUE"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_safe_execute_reraises_does_not_exist_without_column(self): async def test_safe_execute_reraises_does_not_exist_without_column(self):
@@ -534,3 +542,213 @@ class TestSafeExecutePattern:
sql = mock_conn.execute.call_args[0][0].text sql = mock_conn.execute.call_args[0][0].text
assert "::text = '[]'" in sql, f"Expected ::text cast in SQL, got: {sql}" assert "::text = '[]'" in sql, f"Expected ::text cast in SQL, got: {sql}"
assert "printer_ids" in sql assert "printer_ids" in sql
class TestAutoLinkConstraintMigration:
"""Tests for _migrate_update_auto_link_constraint (Fall C / Azure support)."""
@pytest.mark.asyncio
async def test_new_constraint_allows_fall_c_sqlite(self):
"""New formula allows auto_link=TRUE with a custom claim (Fall C)."""
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 oidc_providers_ck ("
"id INTEGER PRIMARY KEY, "
"auto_link BOOLEAN, "
"require_ev BOOLEAN, "
"email_claim TEXT, "
"CHECK (auto_link = FALSE OR email_claim != 'email' OR require_ev = TRUE)"
")"
)
)
# Fall C: custom claim + auto_link + require_ev=FALSE must pass
await conn.execute(text("INSERT INTO oidc_providers_ck VALUES (1, 1, 0, 'upn')"))
# Fall C: custom claim + auto_link + require_ev=TRUE must pass
await conn.execute(text("INSERT INTO oidc_providers_ck VALUES (2, 1, 1, 'preferred_username')"))
await engine.dispose()
@pytest.mark.asyncio
async def test_new_constraint_blocks_fall_b_sqlite(self):
"""New formula still blocks Fall B (email_claim='email' + require_ev=FALSE + auto_link=TRUE)."""
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 oidc_providers_ck ("
"id INTEGER PRIMARY KEY, "
"auto_link BOOLEAN, "
"require_ev BOOLEAN, "
"email_claim TEXT, "
"CHECK (auto_link = FALSE OR email_claim != 'email' OR require_ev = TRUE)"
")"
)
)
async with engine.begin() as conn:
with pytest.raises(IntegrityError):
await conn.execute(text("INSERT INTO oidc_providers_ck VALUES (1, 1, 0, 'email')"))
await engine.dispose()
@pytest.mark.asyncio
async def test_constraint_migration_sqlite_recreates_table(self):
"""SQLite path recreates oidc_providers with new constraint when old formula is present."""
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
from backend.app.core.database import _migrate_update_auto_link_constraint
# Create table with old constraint formula
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as conn:
await conn.execute(
text(
"CREATE TABLE oidc_providers ("
"id INTEGER NOT NULL PRIMARY KEY, "
"name VARCHAR(100) NOT NULL UNIQUE, "
"issuer_url VARCHAR(500) NOT NULL, "
"client_id VARCHAR(255) NOT NULL, "
"client_secret VARCHAR(512) NOT NULL, "
"scopes VARCHAR(500), "
"is_enabled BOOLEAN, "
"auto_create_users BOOLEAN, "
"auto_link_existing_accounts BOOLEAN DEFAULT 0, "
"email_claim VARCHAR(64) DEFAULT 'email', "
"require_email_verified BOOLEAN DEFAULT 1, "
"icon_url TEXT, "
"created_at DATETIME DEFAULT CURRENT_TIMESTAMP, "
"updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, "
"CONSTRAINT ck_auto_link_requires_verified_email_claim "
"CHECK (auto_link_existing_accounts = FALSE OR "
"(require_email_verified = TRUE AND email_claim = 'email'))"
")"
)
)
await conn.execute(
text(
"INSERT INTO oidc_providers (id, name, issuer_url, client_id, client_secret, "
"scopes, is_enabled, auto_create_users, auto_link_existing_accounts, "
"email_claim, require_email_verified, icon_url, created_at, updated_at) "
"VALUES (1, 'TestIdP', 'https://idp.test', 'cid', 'secret', "
"'openid email', 1, 0, 0, 'email', 1, NULL, "
"CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)"
)
)
async with engine.begin() as conn:
with patch("backend.app.core.database.is_sqlite", return_value=True):
await _migrate_update_auto_link_constraint(conn)
# Verify data survived
result = await conn.execute(text("SELECT id, name FROM oidc_providers"))
rows = result.fetchall()
assert len(rows) == 1
assert rows[0][0] == 1
# Verify new constraint: Fall C (auto_link=TRUE + custom claim) must now be insertable
await conn.execute(
text(
"INSERT INTO oidc_providers (id, name, issuer_url, client_id, client_secret, "
"scopes, is_enabled, auto_create_users, auto_link_existing_accounts, "
"email_claim, require_email_verified, icon_url, created_at, updated_at) "
"VALUES (2, 'AzureIdP', 'https://azure.test', 'cid2', 'secret', "
"'openid', 1, 0, 1, 'upn', 1, NULL, "
"CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)"
)
)
# Verify schema has new formula
schema = (
await conn.execute(text("SELECT sql FROM sqlite_master WHERE type='table' AND name='oidc_providers'"))
).fetchone()[0]
assert "require_email_verified = TRUE AND email_claim = 'email'" not in schema
assert "email_claim != 'email'" in schema
await engine.dispose()
@pytest.mark.asyncio
async def test_constraint_migration_postgres_drops_and_recreates(self):
"""PostgreSQL path calls DROP CONSTRAINT IF EXISTS then ADD CONSTRAINT with new formula."""
from unittest.mock import AsyncMock, MagicMock, call
from backend.app.core.database import _migrate_update_auto_link_constraint
# Track all SQL statements passed to _safe_execute by capturing conn.execute calls
executed_sqls: list[str] = []
async def fake_safe_execute(conn, sql):
executed_sqls.append(sql)
nested_cm = MagicMock()
nested_cm.__aenter__ = AsyncMock(return_value=nested_cm)
nested_cm.__aexit__ = AsyncMock(return_value=False)
nested_cm.execute = AsyncMock()
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),
patch("backend.app.core.database._safe_execute", side_effect=fake_safe_execute),
):
await _migrate_update_auto_link_constraint(mock_conn)
assert len(executed_sqls) == 2
drop_sql, add_sql = executed_sqls
assert "DROP CONSTRAINT IF EXISTS" in drop_sql.upper()
assert "ck_auto_link_requires_verified_email_claim" in drop_sql
assert "ADD CONSTRAINT" in add_sql.upper()
assert "email_claim != 'email'" in add_sql
assert "require_email_verified = TRUE AND email_claim = 'email'" not in add_sql
@pytest.mark.asyncio
async def test_constraint_migration_sqlite_count_guard_raises_on_mismatch(self):
"""RuntimeError is raised when the copied row count doesn't match the source."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from backend.app.core.database import _migrate_update_auto_link_constraint
_OLD_SQL = (
"CREATE TABLE oidc_providers (id INTEGER NOT NULL, "
"CONSTRAINT ck_auto_link_requires_verified_email_claim "
"CHECK (auto_link_existing_accounts = FALSE OR "
"(require_email_verified = TRUE AND email_claim = 'email')))"
)
async def fake_execute(stmt):
sql = str(stmt)
result = MagicMock()
if "sqlite_master" in sql:
result.fetchone.return_value = (_OLD_SQL,)
elif "count(*)" in sql.lower() and "oidc_providers_v2" not in sql:
result.scalar_one.return_value = 2 # source has 2 rows
elif "count(*)" in sql.lower() and "oidc_providers_v2" in sql:
result.scalar_one.return_value = 1 # copy only has 1 — mismatch
else:
result.fetchone.return_value = None
return result
nested_cm = MagicMock()
nested_cm.__aenter__ = AsyncMock(return_value=None)
nested_cm.__aexit__ = AsyncMock(return_value=False) # don't suppress exceptions
mock_conn = MagicMock()
mock_conn.execute = AsyncMock(side_effect=fake_execute)
mock_conn.begin_nested.return_value = nested_cm
with (
patch("backend.app.core.database.is_sqlite", return_value=True),
pytest.raises(RuntimeError, match="mismatch"),
):
await _migrate_update_auto_link_constraint(mock_conn)
+1 -14
View File
@@ -207,20 +207,7 @@ if (isMainModule) {
const infoReports = reports.filter((r) => !strictSet.has(codeOf(r.label))); const infoReports = reports.filter((r) => !strictSet.has(codeOf(r.label)));
printReports(strictReports, '=== STRICT locales (failures below fail CI) ==='); printReports(strictReports, '=== STRICT locales (failures below fail CI) ===');
// Informational locales: show per-category drift counts only, not the printReports(infoReports, '=== INFORMATIONAL locales (drift shown, does not fail CI) ===');
// full key lists — the leaf-count table below already gives the overall
// picture. Flip VERBOSE_INFO=1 to dump the full missing-key/placeholder
// reports when actually working on translations.
if (infoReports.length) {
if (process.env.VERBOSE_INFO === '1') {
printReports(infoReports, '=== INFORMATIONAL locales (drift shown, does not fail CI) ===');
} else {
console.error('\n=== INFORMATIONAL locales (drift summary; VERBOSE_INFO=1 for detail) ===');
for (const { label, items } of infoReports) {
console.error(` ${label}: ${items.length}`);
}
}
}
console.log('\nLocale leaf counts:'); console.log('\nLocale leaf counts:');
for (const [code, map] of Object.entries(locales)) { for (const [code, map] of Object.entries(locales)) {
@@ -4,7 +4,7 @@
*/ */
import { describe, it, expect, beforeEach } from 'vitest'; import { describe, it, expect, beforeEach } from 'vitest';
import { screen, waitFor } from '@testing-library/react'; import { screen, waitFor, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { render } from '../utils'; import { render } from '../utils';
import { OIDCProviderSettings } from '../../components/OIDCProviderSettings'; import { OIDCProviderSettings } from '../../components/OIDCProviderSettings';
@@ -107,6 +107,33 @@ describe('OIDCProviderSettings', () => {
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
}); });
it('shows security warning when auto_link is enabled with a custom email claim', async () => {
server.use(http.get('/api/v1/auth/oidc/providers/all', () => HttpResponse.json([])));
const user = userEvent.setup();
render(<OIDCProviderSettings />);
await waitFor(() => {
expect(screen.getAllByRole('button', { name: /Add Provider/i })[0]).toBeInTheDocument();
});
await user.click(screen.getAllByRole('button', { name: /Add Provider/i })[0]);
await waitFor(() => {
expect(screen.getByText(/Auto.*Link/i)).toBeInTheDocument();
});
// Enable auto_link (switch index 2)
const autoLinkSwitch = screen.getAllByRole('switch')[2];
await user.click(autoLinkSwitch);
// Change email claim to a custom value via fireEvent to bypass the onChange fallback
const emailClaimInput = screen.getByPlaceholderText('email');
fireEvent.change(emailClaimInput, { target: { value: 'preferred_username' } });
await waitFor(() => {
expect(screen.getByText(/tenant-administered/i)).toBeInTheDocument();
});
});
}); });
describe('Provider info view', () => { describe('Provider info view', () => {
@@ -123,14 +123,14 @@ function ProviderForm({
<p className="text-bambu-gray text-xs">{t('settings.oidc.form.autoCreateDesc')}</p> <p className="text-bambu-gray text-xs">{t('settings.oidc.form.autoCreateDesc')}</p>
</div> </div>
</label> </label>
<label className="flex items-center gap-3 cursor-pointer"> <label className="flex items-center gap-3 cursor-pointer w-full">
<Toggle checked={form.auto_link_existing_accounts ?? false} onChange={(v) => set('auto_link_existing_accounts', v)} /> <Toggle checked={form.auto_link_existing_accounts ?? false} onChange={(v) => set('auto_link_existing_accounts', v)} />
<div> <div>
<p className="text-white text-sm">{t('settings.oidc.form.autoLink')}</p> <p className="text-white text-sm">{t('settings.oidc.form.autoLink')}</p>
<p className="text-bambu-gray text-xs">{t('settings.oidc.form.autoLinkDesc')}</p> <p className="text-bambu-gray text-xs">{t('settings.oidc.form.autoLinkDesc')}</p>
</div> </div>
</label> </label>
<label className="flex items-center gap-3 cursor-pointer"> <label className="flex items-center gap-3 cursor-pointer w-full">
<Toggle <Toggle
checked={emailVerifiedOn} checked={emailVerifiedOn}
onChange={(v) => set('require_email_verified', v)} onChange={(v) => set('require_email_verified', v)}
@@ -152,6 +152,9 @@ function ProviderForm({
placeholder={t('settings.oidc.form.emailClaimPlaceholder')} placeholder={t('settings.oidc.form.emailClaimPlaceholder')}
/> />
<p className="text-bambu-gray text-xs mt-1">{t('settings.oidc.form.emailClaimDesc')}</p> <p className="text-bambu-gray text-xs mt-1">{t('settings.oidc.form.emailClaimDesc')}</p>
{autoLinkOn && form.email_claim !== 'email' && (
<p className="text-yellow-400 text-xs mt-1">{t('settings.oidc.form.emailClaimCustomClaimAutoLinkWarning')}</p>
)}
</div> </div>
<div className="flex gap-3 pt-2"> <div className="flex gap-3 pt-2">
+1
View File
@@ -2214,6 +2214,7 @@ export default {
emailClaim: 'E-Mail-Claim', emailClaim: 'E-Mail-Claim',
emailClaimDesc: "JWT-Claim für die E-Mail-Identität. Für Azure Entra ID 'preferred_username' oder 'upn' verwenden (sendet kein email_verified). Nur vertrauenswürdige Claim-Namen verwenden.", emailClaimDesc: "JWT-Claim für die E-Mail-Identität. Für Azure Entra ID 'preferred_username' oder 'upn' verwenden (sendet kein email_verified). Nur vertrauenswürdige Claim-Namen verwenden.",
emailClaimPlaceholder: 'email', emailClaimPlaceholder: 'email',
emailClaimCustomClaimAutoLinkWarning: "Benutzerdefinierte Claims sind für die Auto-Verknüpfung nur sicher, wenn der Wert vom Mandanten verwaltet wird (z. B. Azure Entra ID upn / preferred_username). Aktiviere Auto-Verknüpfung nicht, wenn dein IdP Benutzern erlaubt, diesen Claim selbst zu setzen.",
requireEmailVerified: 'E-Mail-Verifizierung erforderlich', requireEmailVerified: 'E-Mail-Verifizierung erforderlich',
requireEmailVerifiedDesc: 'E-Mail-Claim nur akzeptieren, wenn der Provider ihn als verifiziert markiert.', requireEmailVerifiedDesc: 'E-Mail-Claim nur akzeptieren, wenn der Provider ihn als verifiziert markiert.',
requireEmailVerifiedWarning: 'Warnung: E-Mail wird auch ohne Verifizierung akzeptiert. Nur bei vertrauenswürdigen Providern verwenden.', requireEmailVerifiedWarning: 'Warnung: E-Mail wird auch ohne Verifizierung akzeptiert. Nur bei vertrauenswürdigen Providern verwenden.',
+1
View File
@@ -2217,6 +2217,7 @@ export default {
emailClaim: 'Email Claim', emailClaim: 'Email Claim',
emailClaimDesc: "JWT claim used as email identity. Use 'preferred_username' or 'upn' for Azure Entra ID (which does not send email_verified). Only use trusted claim names.", emailClaimDesc: "JWT claim used as email identity. Use 'preferred_username' or 'upn' for Azure Entra ID (which does not send email_verified). Only use trusted claim names.",
emailClaimPlaceholder: 'email', emailClaimPlaceholder: 'email',
emailClaimCustomClaimAutoLinkWarning: "Custom claims are safe for auto-link only when the value is tenant-administered (e.g. Azure Entra ID upn / preferred_username). Do not enable auto-link if your IdP allows users to self-assert this claim.",
requireEmailVerified: 'Require email verified', requireEmailVerified: 'Require email verified',
requireEmailVerifiedDesc: 'Only accept the email claim when the provider marks it as verified.', requireEmailVerifiedDesc: 'Only accept the email claim when the provider marks it as verified.',
requireEmailVerifiedWarning: 'Warning: email will be accepted even without verification. Use only with trusted providers.', requireEmailVerifiedWarning: 'Warning: email will be accepted even without verification. Use only with trusted providers.',
+1
View File
@@ -2150,6 +2150,7 @@ export default {
emailClaim: 'Claim e-mail', emailClaim: 'Claim e-mail',
emailClaimDesc: "Claim JWT utilisé comme identité e-mail. Utiliser 'preferred_username' ou 'upn' pour Azure Entra ID (qui n'envoie pas email_verified). Utiliser uniquement des noms de claims de confiance.", emailClaimDesc: "Claim JWT utilisé comme identité e-mail. Utiliser 'preferred_username' ou 'upn' pour Azure Entra ID (qui n'envoie pas email_verified). Utiliser uniquement des noms de claims de confiance.",
emailClaimPlaceholder: 'email', emailClaimPlaceholder: 'email',
emailClaimCustomClaimAutoLinkWarning: "Custom claims are safe for auto-link only when the value is tenant-administered (e.g. Azure Entra ID upn / preferred_username). Do not enable auto-link if your IdP allows users to self-assert this claim.",
requireEmailVerified: 'Exiger la vérification e-mail', requireEmailVerified: 'Exiger la vérification e-mail',
requireEmailVerifiedDesc: "N'accepter le claim e-mail que si le fournisseur le marque comme vérifié.", requireEmailVerifiedDesc: "N'accepter le claim e-mail que si le fournisseur le marque comme vérifié.",
requireEmailVerifiedWarning: "Avertissement : l'e-mail sera accepté sans vérification. À utiliser uniquement avec des fournisseurs de confiance.", requireEmailVerifiedWarning: "Avertissement : l'e-mail sera accepté sans vérification. À utiliser uniquement avec des fournisseurs de confiance.",
+1
View File
@@ -2149,6 +2149,7 @@ export default {
emailClaim: 'Claim email', emailClaim: 'Claim email',
emailClaimDesc: "Claim JWT usato come identità email. Usare 'preferred_username' o 'upn' per Azure Entra ID (che non invia email_verified). Usare solo nomi di claim affidabili.", emailClaimDesc: "Claim JWT usato come identità email. Usare 'preferred_username' o 'upn' per Azure Entra ID (che non invia email_verified). Usare solo nomi di claim affidabili.",
emailClaimPlaceholder: 'email', emailClaimPlaceholder: 'email',
emailClaimCustomClaimAutoLinkWarning: "Custom claims are safe for auto-link only when the value is tenant-administered (e.g. Azure Entra ID upn / preferred_username). Do not enable auto-link if your IdP allows users to self-assert this claim.",
requireEmailVerified: 'Richiedi verifica email', requireEmailVerified: 'Richiedi verifica email',
requireEmailVerifiedDesc: "Accetta il claim email solo se il provider lo contrassegna come verificato.", requireEmailVerifiedDesc: "Accetta il claim email solo se il provider lo contrassegna come verificato.",
requireEmailVerifiedWarning: 'Attenzione: l\'email sarà accettata senza verifica. Usare solo con provider affidabili.', requireEmailVerifiedWarning: 'Attenzione: l\'email sarà accettata senza verifica. Usare solo con provider affidabili.',
+1
View File
@@ -2188,6 +2188,7 @@ export default {
emailClaim: 'メールクレーム', emailClaim: 'メールクレーム',
emailClaimDesc: "メールIDとして使用するJWTクレーム。Azure Entra IDには'preferred_username'または'upn'を使用(email_verifiedを送信しない)。信頼できるクレーム名のみ使用してください。", emailClaimDesc: "メールIDとして使用するJWTクレーム。Azure Entra IDには'preferred_username'または'upn'を使用(email_verifiedを送信しない)。信頼できるクレーム名のみ使用してください。",
emailClaimPlaceholder: 'email', emailClaimPlaceholder: 'email',
emailClaimCustomClaimAutoLinkWarning: "Custom claims are safe for auto-link only when the value is tenant-administered (e.g. Azure Entra ID upn / preferred_username). Do not enable auto-link if your IdP allows users to self-assert this claim.",
requireEmailVerified: 'メール確認を要求', requireEmailVerified: 'メール確認を要求',
requireEmailVerifiedDesc: 'プロバイダーが確認済みとしてマークした場合にのみメールクレームを受け入れます。', requireEmailVerifiedDesc: 'プロバイダーが確認済みとしてマークした場合にのみメールクレームを受け入れます。',
requireEmailVerifiedWarning: '警告:確認なしでメールが受け入れられます。信頼できるプロバイダーのみで使用してください。', requireEmailVerifiedWarning: '警告:確認なしでメールが受け入れられます。信頼できるプロバイダーのみで使用してください。',
+1
View File
@@ -2149,6 +2149,7 @@ export default {
emailClaim: 'Claim de e-mail', emailClaim: 'Claim de e-mail',
emailClaimDesc: "Claim JWT usado como identidade de e-mail. Use 'preferred_username' ou 'upn' para Azure Entra ID (que não envia email_verified). Use apenas nomes de claim confiáveis.", emailClaimDesc: "Claim JWT usado como identidade de e-mail. Use 'preferred_username' ou 'upn' para Azure Entra ID (que não envia email_verified). Use apenas nomes de claim confiáveis.",
emailClaimPlaceholder: 'email', emailClaimPlaceholder: 'email',
emailClaimCustomClaimAutoLinkWarning: "Custom claims are safe for auto-link only when the value is tenant-administered (e.g. Azure Entra ID upn / preferred_username). Do not enable auto-link if your IdP allows users to self-assert this claim.",
requireEmailVerified: 'Exigir e-mail verificado', requireEmailVerified: 'Exigir e-mail verificado',
requireEmailVerifiedDesc: 'Aceitar o claim de e-mail apenas quando o provedor o marcar como verificado.', requireEmailVerifiedDesc: 'Aceitar o claim de e-mail apenas quando o provedor o marcar como verificado.',
requireEmailVerifiedWarning: 'Aviso: o e-mail será aceito sem verificação. Use apenas com provedores confiáveis.', requireEmailVerifiedWarning: 'Aviso: o e-mail será aceito sem verificação. Use apenas com provedores confiáveis.',
+1
View File
@@ -2201,6 +2201,7 @@ export default {
emailClaim: '邮箱声明', emailClaim: '邮箱声明',
emailClaimDesc: "用作邮箱身份的 JWT 声明。Azure Entra ID 请使用 'preferred_username' 或 'upn'(不发送 email_verified)。仅使用可信的声明名称。", emailClaimDesc: "用作邮箱身份的 JWT 声明。Azure Entra ID 请使用 'preferred_username' 或 'upn'(不发送 email_verified)。仅使用可信的声明名称。",
emailClaimPlaceholder: 'email', emailClaimPlaceholder: 'email',
emailClaimCustomClaimAutoLinkWarning: "Custom claims are safe for auto-link only when the value is tenant-administered (e.g. Azure Entra ID upn / preferred_username). Do not enable auto-link if your IdP allows users to self-assert this claim.",
requireEmailVerified: '要求邮箱已验证', requireEmailVerified: '要求邮箱已验证',
requireEmailVerifiedDesc: '仅在提供商将邮箱声明标记为已验证时才接受。', requireEmailVerifiedDesc: '仅在提供商将邮箱声明标记为已验证时才接受。',
requireEmailVerifiedWarning: '警告:将在未经验证的情况下接受邮箱。仅对受信任的提供商使用。', requireEmailVerifiedWarning: '警告:将在未经验证的情况下接受邮箱。仅对受信任的提供商使用。',
+1
View File
@@ -2199,6 +2199,7 @@ export default {
emailClaim: '電子郵件聲明', emailClaim: '電子郵件聲明',
emailClaimDesc: "用作電子郵件身份的 JWT 聲明。Azure Entra ID 請使用 'preferred_username' 或 'upn'(不發送 email_verified)。僅使用可信的聲明名稱。", emailClaimDesc: "用作電子郵件身份的 JWT 聲明。Azure Entra ID 請使用 'preferred_username' 或 'upn'(不發送 email_verified)。僅使用可信的聲明名稱。",
emailClaimPlaceholder: 'email', emailClaimPlaceholder: 'email',
emailClaimCustomClaimAutoLinkWarning: "Custom claims are safe for auto-link only when the value is tenant-administered (e.g. Azure Entra ID upn / preferred_username). Do not enable auto-link if your IdP allows users to self-assert this claim.",
requireEmailVerified: '要求電子郵件已驗證', requireEmailVerified: '要求電子郵件已驗證',
requireEmailVerifiedDesc: '僅在提供商將電子郵件聲明標記為已驗證時才接受。', requireEmailVerifiedDesc: '僅在提供商將電子郵件聲明標記為已驗證時才接受。',
requireEmailVerifiedWarning: '警告:將在未經驗證的情況下接受電子郵件。僅對受信任的提供商使用。', requireEmailVerifiedWarning: '警告:將在未經驗證的情況下接受電子郵件。僅對受信任的提供商使用。',