feat(oidc): test auto/manual oidc account link/unlink
This commit is contained in:
@@ -84,6 +84,17 @@ let emailVerifiedState = true;
|
|||||||
// setting to the pinned value explicitly).
|
// setting to the pinned value explicitly).
|
||||||
let emailOverride = null;
|
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
|
// Pre-generate the signing keypair. oidc-provider v9 accepts private
|
||||||
// JWKs via configuration.jwks and exports the public halves at
|
// JWKs via configuration.jwks and exports the public halves at
|
||||||
// /jwks.json; keeping our own reference to the private key means we
|
// /jwks.json; keeping our own reference to the private key means we
|
||||||
@@ -162,7 +173,8 @@ const configuration = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async findAccount(_ctx, sub) {
|
async findAccount(_ctx, sub) {
|
||||||
if (sub !== TEST_USER_SUB) return undefined;
|
const currentSub = subOverride ?? TEST_USER_SUB;
|
||||||
|
if (sub !== currentSub) return undefined;
|
||||||
return {
|
return {
|
||||||
accountId: sub,
|
accountId: sub,
|
||||||
// Return EVERY claim the OIDC client could ask for. The provider
|
// 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.
|
// a granted scope are dropped from the ID token / userinfo.
|
||||||
async claims() {
|
async claims() {
|
||||||
return {
|
return {
|
||||||
sub: TEST_USER_SUB,
|
sub: currentSub,
|
||||||
email: emailOverride ?? TEST_USER_EMAIL,
|
email: emailOverride ?? TEST_USER_EMAIL,
|
||||||
email_verified: emailVerifiedState,
|
email_verified: emailVerifiedState,
|
||||||
name: TEST_USER_NAME,
|
name: TEST_USER_NAME,
|
||||||
@@ -257,6 +269,52 @@ async function handleControl(req, res) {
|
|||||||
res.setHeader('content-type', 'application/json');
|
res.setHeader('content-type', 'application/json');
|
||||||
return res.end(JSON.stringify({ email_verified: false }));
|
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
|
// Swap the IdP-returned email to test the OIDC-account-linking
|
||||||
// safety checks (email match, +alias normalization, mismatch refusal).
|
// safety checks (email match, +alias normalization, mismatch refusal).
|
||||||
// Body: `{ email: "alice@example.com" }` — or `null`/`""` to reset
|
// Body: `{ email: "alice@example.com" }` — or `null`/`""` to reset
|
||||||
@@ -406,14 +464,14 @@ async function handleAuto(req, res) {
|
|||||||
return provider.interactionFinished(
|
return provider.interactionFinished(
|
||||||
req,
|
req,
|
||||||
res,
|
res,
|
||||||
{ login: { accountId: TEST_USER_SUB } },
|
{ login: { accountId: subOverride ?? TEST_USER_SUB } },
|
||||||
{ mergeWithLastSubmission: false },
|
{ mergeWithLastSubmission: false },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (name === 'consent') {
|
if (name === 'consent') {
|
||||||
const grant = new provider.Grant({
|
const grant = new provider.Grant({
|
||||||
accountId: TEST_USER_SUB,
|
accountId: subOverride ?? TEST_USER_SUB,
|
||||||
clientId: params.client_id,
|
clientId: params.client_id,
|
||||||
});
|
});
|
||||||
if (params.scope) grant.addOIDCScope(params.scope);
|
if (params.scope) grant.addOIDCScope(params.scope);
|
||||||
|
|||||||
+516
-77
@@ -8,53 +8,70 @@
|
|||||||
# the OIDC suite so the fake-IdP + OxiCloud server are already
|
# the OIDC suite so the fake-IdP + OxiCloud server are already
|
||||||
# up.
|
# up.
|
||||||
#
|
#
|
||||||
# Scenarios covered here:
|
# ENTRY STATE (post-oidc.hurl):
|
||||||
# 1. Auto-link on OIDC login when an existing local user's
|
# * `admin` — local, password auth, email admin@example.com,
|
||||||
# email matches the IdP-returned email + email_verified=true.
|
# NOT federation-linked.
|
||||||
# 2. Unlink success — local admin unlinks their OIDC identity.
|
# * `oidc_user` — JIT-provisioned, federation_kind='oidc',
|
||||||
# 3. Unlink refused when no alternative auth is available.
|
# issuer=<fake_idp>, 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):
|
# Scenarios covered here (indexed against
|
||||||
# - Self-service link/unlink via `POST /api/auth/oidc/link/start`
|
# docs/plan/oidc-account-linking.md § Hurl test coverage):
|
||||||
# from an authenticated session (browser-driven flow;
|
#
|
||||||
# Hurl-simulating the two-hop authorize dance from an
|
# [Refusals on the default sub]
|
||||||
# already-authenticated session with cookies is doable but
|
# — Self-service link routing: POST /link/start returns the
|
||||||
# larger than the current scope).
|
# expected authorize URL.
|
||||||
# - Email mismatch refusal (needs `/control/set-email` on the
|
# — Unlink idempotency: POST /unlink on an unlinked user is
|
||||||
# fake IdP + a follow-through OIDC flow to prove refusal).
|
# a 200 no-op.
|
||||||
# - +alias normalization equivalence.
|
# 6. Self-service link email_mismatch (default email vs admin).
|
||||||
# - Ambiguous-email refusal (needs two OxiCloud users
|
# 8. Self-service link already_linked_elsewhere (email swapped
|
||||||
# normalizing to the same email).
|
# 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
|
# Step 1 — Log in as local admin (password auth path).
|
||||||
# is not possible across files, so we re-fetch by logging in as
|
|
||||||
# the local admin the setup step created.
|
|
||||||
#
|
#
|
||||||
# The admin's email was set by tests/oidc/test.env as
|
# Captures the double-submit CSRF cookie the SPA reads and
|
||||||
# `admin@example.com` — deliberately DIFFERENT from the fake IdP's
|
# mirrors into the X-CSRF-Token header on every mutating
|
||||||
# TEST_USER_EMAIL (`oidc@example.com`), so the earlier OIDC login
|
# request. Every authenticated POST/PATCH/PUT/DELETE below
|
||||||
# flow JIT-provisioned a fresh `oidc_user` instead of auto-linking
|
# MUST include the header or hit CSRF middleware refusal (403).
|
||||||
# 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)
|
|
||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
POST {{base_url}}/api/auth/login
|
POST {{base_url}}/api/auth/login
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
@@ -66,10 +83,6 @@ Content-Type: application/json
|
|||||||
HTTP 200
|
HTTP 200
|
||||||
[Captures]
|
[Captures]
|
||||||
admin_access_token: cookie "oxicloud_access"
|
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"
|
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
|
# Step 3 — Routing check for /link/start. Returns the authorize
|
||||||
# authorize URL that would take them to the IdP. We
|
# URL the SPA would use to full-page-navigate to the
|
||||||
# don't follow the redirect here (the round-trip IS
|
# IdP. We don't follow it here; scenarios below own
|
||||||
# exercised by tests/oidc/oidc.hurl's login flow); this
|
# the round-trip.
|
||||||
# asserts the endpoint routes correctly and returns the
|
|
||||||
# expected shape.
|
|
||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
POST {{base_url}}/api/auth/oidc/link/start
|
POST {{base_url}}/api/auth/oidc/link/start
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
@@ -103,14 +114,14 @@ X-CSRF-Token: {{admin_csrf_token}}
|
|||||||
|
|
||||||
HTTP 200
|
HTTP 200
|
||||||
[Asserts]
|
[Asserts]
|
||||||
# The authorize URL points at the fake IdP with the OAuth2 dance.
|
|
||||||
jsonpath "$.authorize_url" matches "^{{oidc_issuer}}/auth\\?response_type=code&"
|
jsonpath "$.authorize_url" matches "^{{oidc_issuer}}/auth\\?response_type=code&"
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
# Step 4 — Admin has a local password, so unlinking is SAFE
|
# Step 4 — Idempotent unlink on an unlinked user is a 200 no-op.
|
||||||
# (no alt-auth guard triggers). But admin isn't linked,
|
# Admin has a local password, so the no_alternative_auth
|
||||||
# so the unlink is a NO-OP success (idempotent).
|
# guard wouldn't fire even if there were something to
|
||||||
|
# unlink.
|
||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
POST {{base_url}}/api/auth/oidc/unlink
|
POST {{base_url}}/api/auth/oidc/unlink
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
@@ -120,12 +131,447 @@ X-CSRF-Token: {{admin_csrf_token}}
|
|||||||
HTTP 200
|
HTTP 200
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────
|
# ═════════════════════════════════════════════════════════════
|
||||||
# Step 5 — Fresh OIDC login as oidc_user (the JIT-provisioned
|
# Step 5 (Scenario 6) — Self-service link, email_mismatch refusal.
|
||||||
# federated user). Uses the same authorize → callback →
|
# ═════════════════════════════════════════════════════════════
|
||||||
# exchange dance as oidc.hurl Step 9 (existing-user
|
# Default fake-IdP state (emailOverride=null) returns
|
||||||
# re-login).
|
# 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=<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=<fake>,
|
||||||
|
# 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
|
GET {{base_url}}/api/auth/oidc/authorize
|
||||||
[Options]
|
[Options]
|
||||||
location: false
|
location: false
|
||||||
@@ -154,19 +600,17 @@ HTTP 200
|
|||||||
jsonpath "$.user.username" == "oidc_user"
|
jsonpath "$.user.username" == "oidc_user"
|
||||||
jsonpath "$.user.federation_kind" == "oidc"
|
jsonpath "$.user.federation_kind" == "oidc"
|
||||||
[Captures]
|
[Captures]
|
||||||
oidc_user_access_token: cookie "oxicloud_access"
|
# Fresh CSRF from the OIDC session cookies — the admin CSRFs
|
||||||
# Fresh CSRF from the OIDC session cookies — the previous
|
# won't validate against these new cookies.
|
||||||
# admin_csrf_token was for the admin session and won't validate
|
|
||||||
# against these new cookies.
|
|
||||||
oidc_user_csrf_token: cookie "oxicloud_csrf"
|
oidc_user_csrf_token: cookie "oxicloud_csrf"
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────
|
# oidc_user has no password AND no OPAQUE envelope — unlinking
|
||||||
# Step 6 — oidc_user attempts to unlink. Refused because the JIT
|
# would lock them out entirely. The app service returns
|
||||||
# user has NO password and NO OPAQUE envelope — unlinking
|
# DomainError::AccessDenied with reason=no_alternative_auth in
|
||||||
# would lock them out. The backend guard fires with
|
# the audit log; the handler translates the generic AccessDenied
|
||||||
# reason=no_alternative_auth → 403.
|
# 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
|
POST {{base_url}}/api/auth/oidc/unlink
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
X-CSRF-Token: {{oidc_user_csrf_token}}
|
X-CSRF-Token: {{oidc_user_csrf_token}}
|
||||||
@@ -174,15 +618,10 @@ X-CSRF-Token: {{oidc_user_csrf_token}}
|
|||||||
|
|
||||||
HTTP 403
|
HTTP 403
|
||||||
[Asserts]
|
[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"
|
jsonpath "$.error_type" == "NoAlternativeAuth"
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────
|
# Post-refusal invariant: the OIDC identity is still linked.
|
||||||
# Step 7 — Verify unlink was refused: /me still shows the OIDC
|
|
||||||
# identity linked.
|
|
||||||
# ─────────────────────────────────────────────────────────────
|
|
||||||
GET {{base_url}}/api/auth/me
|
GET {{base_url}}/api/auth/me
|
||||||
|
|
||||||
HTTP 200
|
HTTP 200
|
||||||
|
|||||||
Reference in New Issue
Block a user