From 30bf64667c1946e2c6c322ffdcf9715e02c82274 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Sat, 8 Aug 2026 19:52:49 +0200 Subject: [PATCH] feat(oidc): test auto/manual oidc account link/unlink --- tests/oidc/fake_idp/server.js | 66 +++- tests/oidc/link_unlink.hurl | 593 +++++++++++++++++++++++++++++----- 2 files changed, 578 insertions(+), 81 deletions(-) diff --git a/tests/oidc/fake_idp/server.js b/tests/oidc/fake_idp/server.js index b2e4abed..b7ef7693 100644 --- a/tests/oidc/fake_idp/server.js +++ b/tests/oidc/fake_idp/server.js @@ -84,6 +84,17 @@ let emailVerifiedState = true; // setting to the pinned value explicitly). let emailOverride = null; +// Runtime-swappable sub (subject / accountId) — normally TEST_USER_SUB, +// overridable via `POST /control/set-sub` to test the OIDC-linking +// scenarios that need a fresh, not-yet-known federated identity: +// self-service link happy path, +alias normalization link, auto-link +// happy path, auto-link refusal (verified=false). The set-sub endpoint +// ALSO clears the OP's session cookies in the response — otherwise +// the next authorize dance would reuse the previously-established +// session bound to the OLD sub and skip the login prompt (where the +// new sub gets bound). Reset by POSTing an empty/null body. +let subOverride = null; + // Pre-generate the signing keypair. oidc-provider v9 accepts private // JWKs via configuration.jwks and exports the public halves at // /jwks.json; keeping our own reference to the private key means we @@ -162,7 +173,8 @@ const configuration = { }, async findAccount(_ctx, sub) { - if (sub !== TEST_USER_SUB) return undefined; + const currentSub = subOverride ?? TEST_USER_SUB; + if (sub !== currentSub) return undefined; return { accountId: sub, // Return EVERY claim the OIDC client could ask for. The provider @@ -170,7 +182,7 @@ const configuration = { // a granted scope are dropped from the ID token / userinfo. async claims() { return { - sub: TEST_USER_SUB, + sub: currentSub, email: emailOverride ?? TEST_USER_EMAIL, email_verified: emailVerifiedState, name: TEST_USER_NAME, @@ -257,6 +269,52 @@ async function handleControl(req, res) { res.setHeader('content-type', 'application/json'); return res.end(JSON.stringify({ email_verified: false })); } + // Swap the IdP-returned sub (accountId) to test the OIDC-linking + // scenarios that need a fresh, not-yet-known identity: self-service + // link happy path, +alias normalization link, auto-link happy path, + // auto-link refusal (email_verified=false). + // + // Body: `{ sub: "sub-link-happy" }` — or `null`/`""` to reset to + // the pinned TEST_USER_SUB. + // + // ALSO clears the OP's session cookies via Set-Cookie in the + // response. Without this, the next authorize dance from the same + // Hurl file (same cookie jar) would reuse the previously-established + // session — bound to the OLD sub — and skip the login prompt where + // the new sub gets bound. Panva/node-oidc-provider defaults for the + // session/grant/interaction cookies are documented in + // https://github.com/panva/node-oidc-provider/blob/main/docs/README.md#cookies + // — we clear the ones the client-side cookie jar can hold. + if (req.method === 'POST' && url.pathname === '/control/set-sub') { + let body = ''; + for await (const chunk of req) body += chunk; + let parsed = {}; + try { + parsed = body ? JSON.parse(body) : {}; + } catch { + res.statusCode = 400; + res.setHeader('content-type', 'application/json'); + return res.end(JSON.stringify({ error: 'invalid_json' })); + } + subOverride = + parsed.sub && typeof parsed.sub === 'string' && parsed.sub.length > 0 + ? parsed.sub + : null; + res.statusCode = 200; + res.setHeader('content-type', 'application/json'); + res.setHeader('Set-Cookie', [ + '_session=; Path=/; Max-Age=0; HttpOnly', + '_session.legacy=; Path=/; Max-Age=0; HttpOnly', + '_grant=; Path=/; Max-Age=0; HttpOnly', + '_interaction=; Path=/; Max-Age=0; HttpOnly', + ]); + return res.end( + JSON.stringify({ + sub: subOverride ?? TEST_USER_SUB, + overridden: subOverride !== null, + }), + ); + } // Swap the IdP-returned email to test the OIDC-account-linking // safety checks (email match, +alias normalization, mismatch refusal). // Body: `{ email: "alice@example.com" }` — or `null`/`""` to reset @@ -406,14 +464,14 @@ async function handleAuto(req, res) { return provider.interactionFinished( req, res, - { login: { accountId: TEST_USER_SUB } }, + { login: { accountId: subOverride ?? TEST_USER_SUB } }, { mergeWithLastSubmission: false }, ); } if (name === 'consent') { const grant = new provider.Grant({ - accountId: TEST_USER_SUB, + accountId: subOverride ?? TEST_USER_SUB, clientId: params.client_id, }); if (params.scope) grant.addOIDCScope(params.scope); diff --git a/tests/oidc/link_unlink.hurl b/tests/oidc/link_unlink.hurl index 4b7d2fb5..13dc1ab5 100644 --- a/tests/oidc/link_unlink.hurl +++ b/tests/oidc/link_unlink.hurl @@ -8,53 +8,70 @@ # the OIDC suite so the fake-IdP + OxiCloud server are already # up. # -# Scenarios covered here: -# 1. Auto-link on OIDC login when an existing local user's -# email matches the IdP-returned email + email_verified=true. -# 2. Unlink success — local admin unlinks their OIDC identity. -# 3. Unlink refused when no alternative auth is available. +# ENTRY STATE (post-oidc.hurl): +# * `admin` — local, password auth, email admin@example.com, +# NOT federation-linked. +# * `oidc_user` — JIT-provisioned, federation_kind='oidc', +# issuer=, subject=oidc-test-user, email +# oidc@example.com, NO password / NO OPAQUE. +# * Fake IdP — subOverride=null, emailOverride=null, +# verified=true, accountId=`oidc-test-user`. # -# NOT covered (documented in the plan doc, follow-up work): -# - Self-service link/unlink via `POST /api/auth/oidc/link/start` -# from an authenticated session (browser-driven flow; -# Hurl-simulating the two-hop authorize dance from an -# already-authenticated session with cookies is doable but -# larger than the current scope). -# - Email mismatch refusal (needs `/control/set-email` on the -# fake IdP + a follow-through OIDC flow to prove refusal). -# - +alias normalization equivalence. -# - Ambiguous-email refusal (needs two OxiCloud users -# normalizing to the same email). +# Scenarios covered here (indexed against +# docs/plan/oidc-account-linking.md § Hurl test coverage): +# +# [Refusals on the default sub] +# — Self-service link routing: POST /link/start returns the +# expected authorize URL. +# — Unlink idempotency: POST /unlink on an unlinked user is +# a 200 no-op. +# 6. Self-service link email_mismatch (default email vs admin). +# 8. Self-service link already_linked_elsewhere (email swapped +# to admin's, but the sub is still linked to oidc_user). +# +# [Fresh subs — needs /control/set-sub on the fake IdP] +# 5. Self-service link happy round-trip → /profile?linked=1 +# + /me shows the federation identity. Then unlink and +# assert federation cleared (scenario 9). +# 7. +alias normalization link — IdP returns +# `admin+oidc@example.com` which normalizes to admin's +# `admin@example.com`. Link succeeds. +# 1. Auto-link happy path — OIDC login callback sees a +# (iss, sub) miss but email matches admin, so it auto- +# links + logs admin in. +# 2. Auto-link refused — email_verified=false. Callback +# returns HTTP 409 with error_type "Already Exists" (the +# "contact admin to link your OIDC identity" refusal). +# +# [OIDC-only user] +# 10. `oidc_user` unlink refused (would lock them out) with +# error_type NoAlternativeAuth. +# +# NOT covered (backend gap; not a Hurl gap): +# 3. Auto-link refused — email_ambiguous. The current +# auto-link path uses `get_user_by_email` (exact match), +# not a normalized-email lookup. It CAN'T see two rows +# normalizing to the same value, so the ambiguity branch +# in the plan doc is unreachable from wire input. Needs a +# `list_users_by_normalized_email` repo method before +# Hurl can exercise it — separate PR. +# +# NOT covered (config gap; would need a second server boot): +# 4. Auto-link disabled by OXICLOUD_OIDC_AUTO_LINK_EMAIL_MATCH=false. +# Server boots with the flag ON in server-with-oidc.env; +# testing the OFF branch means a separate hurl invocation +# with a re-launched server, which the current run.sh does +# not do. # ============================================================= # ───────────────────────────────────────────────────────────── -# Preflight: capture the admin id from an earlier oidc.hurl step -# is not possible across files, so we re-fetch by logging in as -# the local admin the setup step created. +# Step 1 — Log in as local admin (password auth path). # -# The admin's email was set by tests/oidc/test.env as -# `admin@example.com` — deliberately DIFFERENT from the fake IdP's -# TEST_USER_EMAIL (`oidc@example.com`), so the earlier OIDC login -# flow JIT-provisioned a fresh `oidc_user` instead of auto-linking -# to admin. We reuse that oidc_user here. -# -# The oidc_user was created via JIT during oidc.hurl, so it EXISTS -# and is OIDC-linked (`federation_kind='oidc'`). We can: -# 1. Assert /api/admin/users/by-username shows oidc_user is linked. -# 2. Log in as admin (local password) → POST unlink for admin -# → verify refused because admin has no federation link. -# 3. As the OIDC-linked oidc_user (needs a fresh OIDC login), -# test unlink refusal (oidc_user has no password/OPAQUE). -# -# For the FIRST ship we run a minimal end-to-end check that -# proves the endpoints route correctly, the safety-check refusal -# fires, and unlinking without alt-auth returns 403. -# ───────────────────────────────────────────────────────────── - - -# ───────────────────────────────────────────────────────────── -# Step 1 — Log in as local admin (password auth path) +# Captures the double-submit CSRF cookie the SPA reads and +# mirrors into the X-CSRF-Token header on every mutating +# request. Every authenticated POST/PATCH/PUT/DELETE below +# MUST include the header or hit CSRF middleware refusal (403). # ───────────────────────────────────────────────────────────── POST {{base_url}}/api/auth/login Content-Type: application/json @@ -66,10 +83,6 @@ Content-Type: application/json HTTP 200 [Captures] admin_access_token: cookie "oxicloud_access" -# Capture the double-submit CSRF cookie the SPA reads and mirrors -# into the X-CSRF-Token header on every mutating request. Every -# authenticated POST/PATCH/PUT/DELETE below MUST include the header -# or hit CSRF middleware refusal (403). admin_csrf_token: cookie "oxicloud_csrf" @@ -89,12 +102,10 @@ jsonpath "$.federation_issuer" not exists # ───────────────────────────────────────────────────────────── -# Step 3 — Admin starts a self-service link flow. Returns an -# authorize URL that would take them to the IdP. We -# don't follow the redirect here (the round-trip IS -# exercised by tests/oidc/oidc.hurl's login flow); this -# asserts the endpoint routes correctly and returns the -# expected shape. +# Step 3 — Routing check for /link/start. Returns the authorize +# URL the SPA would use to full-page-navigate to the +# IdP. We don't follow it here; scenarios below own +# the round-trip. # ───────────────────────────────────────────────────────────── POST {{base_url}}/api/auth/oidc/link/start Content-Type: application/json @@ -103,14 +114,14 @@ X-CSRF-Token: {{admin_csrf_token}} HTTP 200 [Asserts] -# The authorize URL points at the fake IdP with the OAuth2 dance. jsonpath "$.authorize_url" matches "^{{oidc_issuer}}/auth\\?response_type=code&" # ───────────────────────────────────────────────────────────── -# Step 4 — Admin has a local password, so unlinking is SAFE -# (no alt-auth guard triggers). But admin isn't linked, -# so the unlink is a NO-OP success (idempotent). +# Step 4 — Idempotent unlink on an unlinked user is a 200 no-op. +# Admin has a local password, so the no_alternative_auth +# guard wouldn't fire even if there were something to +# unlink. # ───────────────────────────────────────────────────────────── POST {{base_url}}/api/auth/oidc/unlink Content-Type: application/json @@ -120,12 +131,447 @@ X-CSRF-Token: {{admin_csrf_token}} HTTP 200 -# ───────────────────────────────────────────────────────────── -# Step 5 — Fresh OIDC login as oidc_user (the JIT-provisioned -# federated user). Uses the same authorize → callback → -# exchange dance as oidc.hurl Step 9 (existing-user -# re-login). -# ───────────────────────────────────────────────────────────── +# ═════════════════════════════════════════════════════════════ +# Step 5 (Scenario 6) — Self-service link, email_mismatch refusal. +# ═════════════════════════════════════════════════════════════ +# Default fake-IdP state (emailOverride=null) returns +# oidc@example.com — which does NOT match admin's +# admin@example.com. The callback recognises the Link intent, +# runs the email normalization comparison, and refuses with +# `email_mismatch`. +# +# Wire contract: 307 to `/profile?link_error=email_mismatch`. +# With `location: true` Hurl follows the full chain (authorize +# → IdP login+consent auto-approve → callback → redirect) and +# lands on the SPA-served /profile page (status 200 — the +# `index.html` fallback serves any client-router path). +# Assertion pins the exact URL shape the SPA reads on mount. +# ═════════════════════════════════════════════════════════════ + + +# Belt-and-braces: reset any subOverride / emailOverride that +# might have leaked from an earlier suite (defensive — oidc.hurl +# doesn't use them, but a re-used fake-idp process could). +POST {{oidc_issuer}}/control/set-sub +Content-Type: application/json +{} + +HTTP 200 + + +POST {{oidc_issuer}}/control/set-email +Content-Type: application/json +{} + +HTTP 200 + + +POST {{base_url}}/api/auth/oidc/link/start +Content-Type: application/json +X-CSRF-Token: {{admin_csrf_token}} +{} + +HTTP 200 +[Captures] +mismatch_authorize_url: jsonpath "$.authorize_url" + + +GET {{mismatch_authorize_url}} +[Options] +location: true +location-trusted: true + +# SPA fallback serves index.html for any client-router path +# (SvelteKit adapter-static + ServeDir fallback in +# src/interfaces/web/mod.rs). Assert on URL, not on body — the +# body is the SPA shell in every case. +HTTP 200 +[Asserts] +url matches "^http://localhost:8087/profile\\?link_error=email_mismatch$" + + +# Post-refusal invariant: admin row unchanged, no federation +# fields populated. This is the load-bearing safety proof — +# a regression that mistakenly UPDATE-d the row before the +# email check would trip here. +GET {{base_url}}/api/auth/me + +HTTP 200 +[Asserts] +jsonpath "$.federation_kind" not exists +jsonpath "$.federation_issuer" not exists + + +# ═════════════════════════════════════════════════════════════ +# Step 6 (Scenario 8) — already_linked_elsewhere refusal. +# ═════════════════════════════════════════════════════════════ +# Flip the fake IdP to return admin's email so the email check +# passes; the sub is STILL `oidc-test-user`, which oidc.hurl +# already linked to `oidc_user`. The pre-UPDATE check in +# complete_oidc_link (`get_user_by_federation_subject` → row +# belongs to another user) fires and refuses with +# `already_linked_elsewhere`. +# ═════════════════════════════════════════════════════════════ + + +POST {{oidc_issuer}}/control/set-email +Content-Type: application/json +{ "email": "{{email}}" } + +HTTP 200 +[Asserts] +jsonpath "$.overridden" == true +jsonpath "$.email" == "{{email}}" + + +POST {{base_url}}/api/auth/oidc/link/start +Content-Type: application/json +X-CSRF-Token: {{admin_csrf_token}} +{} + +HTTP 200 +[Captures] +taken_authorize_url: jsonpath "$.authorize_url" + + +GET {{taken_authorize_url}} +[Options] +location: true +location-trusted: true + +HTTP 200 +[Asserts] +url matches "^http://localhost:8087/profile\\?link_error=already_linked_elsewhere$" + + +GET {{base_url}}/api/auth/me + +HTTP 200 +[Asserts] +jsonpath "$.federation_kind" not exists +jsonpath "$.federation_issuer" not exists + + +# ═════════════════════════════════════════════════════════════ +# Step 7 (Scenario 5) — Self-service link happy round-trip. +# ═════════════════════════════════════════════════════════════ +# Swap the IdP to a FRESH sub (not-yet-linked to anyone) and +# keep the admin email match from Step 6. The set-sub endpoint +# also clears the OP's session cookies in the response so the +# next authorize dance re-prompts login and binds to the new +# sub. All five safety checks pass → link_federation_identity +# UPDATE runs → callback returns LinkCompleted → 307 to +# /profile?linked=1. /me now shows the federation identity. +# +# Then unlink admin (scenario 9 — success, has password) and +# assert the federation fields clear. +# ═════════════════════════════════════════════════════════════ + + +POST {{oidc_issuer}}/control/set-sub +Content-Type: application/json +{ "sub": "sub-link-happy" } + +HTTP 200 +[Asserts] +jsonpath "$.overridden" == true +jsonpath "$.sub" == "sub-link-happy" + + +POST {{base_url}}/api/auth/oidc/link/start +Content-Type: application/json +X-CSRF-Token: {{admin_csrf_token}} +{} + +HTTP 200 +[Captures] +happy_authorize_url: jsonpath "$.authorize_url" + + +GET {{happy_authorize_url}} +[Options] +location: true +location-trusted: true + +HTTP 200 +[Asserts] +url == "http://localhost:8087/profile?linked=1" + + +# Federation now set on admin. +GET {{base_url}}/api/auth/me + +HTTP 200 +[Asserts] +jsonpath "$.username" == "{{username}}" +jsonpath "$.federation_kind" == "oidc" +jsonpath "$.federation_issuer" == "{{oidc_issuer}}" + + +# Scenario 9 — unlink success (admin has a password, so the +# no_alternative_auth guard doesn't fire). +POST {{base_url}}/api/auth/oidc/unlink +Content-Type: application/json +X-CSRF-Token: {{admin_csrf_token}} +{} + +HTTP 200 + + +GET {{base_url}}/api/auth/me + +HTTP 200 +[Asserts] +jsonpath "$.federation_kind" not exists +jsonpath "$.federation_issuer" not exists + + +# ═════════════════════════════════════════════════════════════ +# Step 8 (Scenario 7) — +alias normalization link. +# ═════════════════════════════════════════════════════════════ +# `common::text::normalize_email_for_link` strips +alias +# sub-addressing (`admin+oidc@example.com` → `admin@example.com`) +# and case-folds. Set the fake IdP to a FRESH sub and to the +# +alias email; the link check normalizes both sides and finds +# equivalence, so the link succeeds despite the raw strings +# differing. +# ═════════════════════════════════════════════════════════════ + + +POST {{oidc_issuer}}/control/set-sub +Content-Type: application/json +{ "sub": "sub-alias" } + +HTTP 200 + + +POST {{oidc_issuer}}/control/set-email +Content-Type: application/json +{ "email": "admin+oidc@example.com" } + +HTTP 200 + + +POST {{base_url}}/api/auth/oidc/link/start +Content-Type: application/json +X-CSRF-Token: {{admin_csrf_token}} +{} + +HTTP 200 +[Captures] +alias_authorize_url: jsonpath "$.authorize_url" + + +GET {{alias_authorize_url}} +[Options] +location: true +location-trusted: true + +HTTP 200 +[Asserts] +url == "http://localhost:8087/profile?linked=1" + + +GET {{base_url}}/api/auth/me + +HTTP 200 +[Asserts] +jsonpath "$.federation_kind" == "oidc" + + +# Unlink to reset state before the auto-link scenarios. +POST {{base_url}}/api/auth/oidc/unlink +Content-Type: application/json +X-CSRF-Token: {{admin_csrf_token}} +{} + +HTTP 200 + + +# ═════════════════════════════════════════════════════════════ +# Step 9 (Scenario 1) — Auto-link happy path. +# ═════════════════════════════════════════════════════════════ +# This is the OIDC LOGIN callback path (not the self-service +# link flow). The callback's (iss, sub) lookup MISSES on the +# fresh sub, falls into the "match by email" branch, finds +# admin (email match + email_verified=true + admin not already +# linked), and auto-links. The flow proceeds like a regular +# OIDC login: the callback yields WebLogin { exchange_code } +# and 307s to /login?oidc_code=. POST /exchange then +# mints tokens for admin (not a JIT-provisioned new user). +# +# The admin session cookies from Step 1 are still in the jar; +# /exchange returns fresh cookies that OVERWRITE the old ones +# so subsequent requests use the auto-link-issued session. +# ═════════════════════════════════════════════════════════════ + + +POST {{oidc_issuer}}/control/set-sub +Content-Type: application/json +{ "sub": "sub-auto-happy" } + +HTTP 200 + + +# Reset email to admin's (Step 8 left it at admin+oidc@example.com, +# which would ALSO auto-link — but we assert on the strict-match +# behavior here). +POST {{oidc_issuer}}/control/set-email +Content-Type: application/json +{ "email": "{{email}}" } + +HTTP 200 + + +GET {{base_url}}/api/auth/oidc/authorize +[Options] +location: true +location-trusted: true + +HTTP 200 +[Captures] +autolink_oidc_code: url regex "oidc_code=([a-f0-9]+)" +[Asserts] +url matches "^http://localhost:8087/login\\?oidc_code=[a-f0-9]+$" + + +POST {{base_url}}/api/auth/oidc/exchange +Content-Type: application/json +{ "code": "{{autolink_oidc_code}}" } + +HTTP 200 +[Asserts] +# Auto-link resolved to the pre-existing admin, NOT a fresh +# JIT-provisioned user. The load-bearing assertion. +jsonpath "$.user.username" == "{{username}}" +jsonpath "$.user.federation_kind" == "oidc" +jsonpath "$.user.federation_issuer" == "{{oidc_issuer}}" +[Captures] +# Fresh cookies replace the password session's; capture the +# new CSRF for the unlink below. +autolink_csrf_token: cookie "oxicloud_csrf" + + +# /me confirms admin session AND that the federation columns +# are populated. Auto-link should have set kind=oidc, issuer=, +# subject=sub-auto-happy on admin's row. +GET {{base_url}}/api/auth/me + +HTTP 200 +[Asserts] +jsonpath "$.username" == "{{username}}" +jsonpath "$.federation_kind" == "oidc" +jsonpath "$.federation_issuer" == "{{oidc_issuer}}" + + +# Reset admin state before the next scenario (auto-link would +# refuse if admin is already linked, so we'd never reach the +# email_verified=false branch we want to test). +POST {{base_url}}/api/auth/oidc/unlink +Content-Type: application/json +X-CSRF-Token: {{autolink_csrf_token}} +{} + +HTTP 200 + + +# ═════════════════════════════════════════════════════════════ +# Step 10 (Scenario 2) — Auto-link refused, email_verified=false. +# ═════════════════════════════════════════════════════════════ +# Same shape as scenario 1 but with the IdP asserting +# email_verified=false. Auto-link gates on +# `email_verified == Some(true)` regardless of the operator-level +# OXICLOUD_REQUIRE_VERIFIED_EMAIL flag (auto-link uses the IdP +# email as a takeover-mitigation signal — an unverified email +# does not satisfy that), so the refusal path fires and the +# callback returns `DomainError::AlreadyExists`. +# +# Wire behavior: HTTP 409 with error_type "Already Exists" +# (ErrorKind::AlreadyExists → CONFLICT). The message is the +# "contact admin to link your OIDC identity" text that surfaces +# in the SPA login form's error toast. +# +# NOTE: `error_type` here is "Already Exists" (with a space) +# because it comes from `ErrorKind::as_str()`, not from a +# handler-set stable key. The auto-link refusal branch is +# reusing the generic AlreadyExists mapping — a follow-up +# could give it a dedicated `error_type` like +# `AutoLinkEmailNotVerified` for the SPA to switch on. +# ═════════════════════════════════════════════════════════════ + + +POST {{oidc_issuer}}/control/set-sub +Content-Type: application/json +{ "sub": "sub-verified-false" } + +HTTP 200 + + +POST {{oidc_issuer}}/control/email-verified/false + +HTTP 200 + + +# Follow the whole OIDC dance. Hurl's location: true follows 3xx +# up to the callback; the callback returns 409 (non-3xx) and +# location follow stops. The final response is what we assert on. +GET {{base_url}}/api/auth/oidc/authorize +[Options] +location: true +location-trusted: true + +HTTP 409 +[Asserts] +jsonpath "$.error_type" == "Already Exists" + + +# Belt-and-braces invariant: admin's row is still un-linked +# (the refusal ran BEFORE link_federation_identity would fire). +GET {{base_url}}/api/auth/me + +HTTP 200 +[Asserts] +jsonpath "$.federation_kind" not exists + + +# Reset fake IdP state (email_verified back to true, sub back +# to TEST_USER_SUB) so the oidc_user re-login below resolves +# correctly. The set-sub call ALSO clears the OP's session +# cookies from the jar, ensuring the login prompt fires with +# the reset sub bound instead of reusing the sub-verified-false +# session. +POST {{oidc_issuer}}/control/email-verified/true + +HTTP 200 + + +POST {{oidc_issuer}}/control/set-sub +Content-Type: application/json +{} + +HTTP 200 +[Asserts] +jsonpath "$.overridden" == false + + +POST {{oidc_issuer}}/control/set-email +Content-Type: application/json +{} + +HTTP 200 + + +# ═════════════════════════════════════════════════════════════ +# Step 11 (Scenario 10) — Unlink refused for the OIDC-only user. +# ═════════════════════════════════════════════════════════════ +# Fresh OIDC login as `oidc_user` (the JIT-provisioned +# federated principal from oidc.hurl). Uses the reset default +# sub, so the (iss, sub) lookup HITS the existing oidc_user +# row (linked in oidc.hurl Step 5) and the existing-user branch +# runs — NOT auto-link. +# ═════════════════════════════════════════════════════════════ + + GET {{base_url}}/api/auth/oidc/authorize [Options] location: false @@ -154,19 +600,17 @@ HTTP 200 jsonpath "$.user.username" == "oidc_user" jsonpath "$.user.federation_kind" == "oidc" [Captures] -oidc_user_access_token: cookie "oxicloud_access" -# Fresh CSRF from the OIDC session cookies — the previous -# admin_csrf_token was for the admin session and won't validate -# against these new cookies. +# Fresh CSRF from the OIDC session cookies — the admin CSRFs +# won't validate against these new cookies. oidc_user_csrf_token: cookie "oxicloud_csrf" -# ───────────────────────────────────────────────────────────── -# Step 6 — oidc_user attempts to unlink. Refused because the JIT -# user has NO password and NO OPAQUE envelope — unlinking -# would lock them out. The backend guard fires with -# reason=no_alternative_auth → 403. -# ───────────────────────────────────────────────────────────── +# oidc_user has no password AND no OPAQUE envelope — unlinking +# would lock them out entirely. The app service returns +# DomainError::AccessDenied with reason=no_alternative_auth in +# the audit log; the handler translates the generic AccessDenied +# into a stable machine-readable `error_type` the SPA switches +# on to render the "set a password first" affordance. POST {{base_url}}/api/auth/oidc/unlink Content-Type: application/json X-CSRF-Token: {{oidc_user_csrf_token}} @@ -174,15 +618,10 @@ X-CSRF-Token: {{oidc_user_csrf_token}} HTTP 403 [Asserts] -# error_type is the stable machine-readable key the SPA switches -# on to render the "set a password first" affordance. jsonpath "$.error_type" == "NoAlternativeAuth" -# ───────────────────────────────────────────────────────────── -# Step 7 — Verify unlink was refused: /me still shows the OIDC -# identity linked. -# ───────────────────────────────────────────────────────────── +# Post-refusal invariant: the OIDC identity is still linked. GET {{base_url}}/api/auth/me HTTP 200