feat(oidc): permit auto/manual oidc account link/unlink
link are checking that email matches, +email alias are normalize into email if email is already used on another account, link is not possible not usurpation risk as the IDP is choosen by the admin
This commit is contained in:
@@ -76,6 +76,14 @@ const BCL_EVENT = 'http://schemas.openid.net/event/backchannel-logout';
|
||||
// claims() callback.
|
||||
let emailVerifiedState = true;
|
||||
|
||||
// Runtime-swappable email — normally the pinned TEST_USER_EMAIL, but
|
||||
// the OIDC-account-linking Hurl suite flips it via
|
||||
// `POST /control/set-email` to test the auto-link + self-service-link
|
||||
// safety checks: email mismatch refusal, +alias normalization
|
||||
// equivalence, etc. Reset by `POST /control/reset-email` (or by
|
||||
// setting to the pinned value explicitly).
|
||||
let emailOverride = 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
|
||||
@@ -163,7 +171,7 @@ const configuration = {
|
||||
async claims() {
|
||||
return {
|
||||
sub: TEST_USER_SUB,
|
||||
email: TEST_USER_EMAIL,
|
||||
email: emailOverride ?? TEST_USER_EMAIL,
|
||||
email_verified: emailVerifiedState,
|
||||
name: TEST_USER_NAME,
|
||||
given_name: TEST_USER_GIVEN_NAME,
|
||||
@@ -249,6 +257,34 @@ async function handleControl(req, res) {
|
||||
res.setHeader('content-type', 'application/json');
|
||||
return res.end(JSON.stringify({ email_verified: false }));
|
||||
}
|
||||
// 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
|
||||
// to the pinned TEST_USER_EMAIL.
|
||||
if (req.method === 'POST' && url.pathname === '/control/set-email') {
|
||||
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' }));
|
||||
}
|
||||
emailOverride =
|
||||
parsed.email && typeof parsed.email === 'string' && parsed.email.length > 0
|
||||
? parsed.email
|
||||
: null;
|
||||
res.statusCode = 200;
|
||||
res.setHeader('content-type', 'application/json');
|
||||
return res.end(
|
||||
JSON.stringify({
|
||||
email: emailOverride ?? TEST_USER_EMAIL,
|
||||
overridden: emailOverride !== null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (req.method === 'POST' && url.pathname === '/control/backchannel-logout') {
|
||||
// Body shape: `{ sub?: string, sid?: string }`. Optional so the test
|
||||
// can exercise both revocation modes:
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
# =============================================================
|
||||
# OxiCloud — OIDC account link / unlink coverage
|
||||
# =============================================================
|
||||
# Complements tests/oidc/oidc.hurl (which exercises the login
|
||||
# flow end-to-end). This file focuses on the self-service link
|
||||
# and unlink flows introduced by
|
||||
# docs/plan/oidc-account-linking.md. It runs AFTER oidc.hurl in
|
||||
# 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.
|
||||
#
|
||||
# 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).
|
||||
# =============================================================
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 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.
|
||||
#
|
||||
# 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)
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/auth/login
|
||||
Content-Type: application/json
|
||||
{
|
||||
"username": "{{username}}",
|
||||
"password": "{{password}}"
|
||||
}
|
||||
|
||||
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"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 2 — Admin is NOT federated; /api/auth/me shows federation
|
||||
# fields absent (null / omitted).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
GET {{base_url}}/api/auth/me
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$.username" == "{{username}}"
|
||||
# federation_kind is skip_serializing_if=Option::is_none, so a
|
||||
# local user's response OMITS the field entirely.
|
||||
jsonpath "$.federation_kind" not exists
|
||||
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.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/auth/oidc/link/start
|
||||
Content-Type: application/json
|
||||
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).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/auth/oidc/unlink
|
||||
Content-Type: application/json
|
||||
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).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
GET {{base_url}}/api/auth/oidc/authorize
|
||||
[Options]
|
||||
location: false
|
||||
|
||||
HTTP 307
|
||||
[Captures]
|
||||
oidc_idp_url: header "Location"
|
||||
|
||||
|
||||
GET {{oidc_idp_url}}
|
||||
[Options]
|
||||
location: true
|
||||
location-trusted: true
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
oidc_code: url regex "oidc_code=([a-f0-9]+)"
|
||||
|
||||
|
||||
POST {{base_url}}/api/auth/oidc/exchange
|
||||
Content-Type: application/json
|
||||
{ "code": "{{oidc_code}}" }
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
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.
|
||||
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.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/auth/oidc/unlink
|
||||
Content-Type: application/json
|
||||
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.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
GET {{base_url}}/api/auth/me
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$.federation_kind" == "oidc"
|
||||
jsonpath "$.federation_issuer" == "{{oidc_issuer}}"
|
||||
+4
-1
@@ -179,10 +179,13 @@ wait_for_http "$base_url/ready" 120
|
||||
log "Server is ready."
|
||||
|
||||
# ── 5. Run the OIDC Hurl suite ─────────────────────────────────────────────
|
||||
# Order matters: oidc.hurl bootstraps the admin and JIT-provisions the
|
||||
# `oidc_user` federated principal that link_unlink.hurl then reuses.
|
||||
log "Running OIDC Hurl tests..."
|
||||
hurl --variables-file "$OIDC_DIR/test.env" \
|
||||
--file-root "$REPO_ROOT/tests" \
|
||||
--test --jobs 1 \
|
||||
"$OIDC_DIR/oidc.hurl"
|
||||
"$OIDC_DIR/oidc.hurl" \
|
||||
"$OIDC_DIR/link_unlink.hurl"
|
||||
|
||||
log "OIDC tests passed."
|
||||
|
||||
Reference in New Issue
Block a user