From 921cbef152a6a247eab28fbad76e5452b6d3f6bc Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Mon, 3 Aug 2026 21:14:54 +0200 Subject: [PATCH] test(oidc): test back-channel logout --- justfile | 2 - tests/oidc/fake_idp/server.js | 128 ++++++++++++++++++++++++++++- tests/oidc/oidc.hurl | 150 ++++++++++++++++++++++++++++++++++ 3 files changed, 276 insertions(+), 4 deletions(-) diff --git a/justfile b/justfile index 91e89dd6..77d70641 100644 --- a/justfile +++ b/justfile @@ -1,5 +1,3 @@ -set dotenv-load - default: @just --list diff --git a/tests/oidc/fake_idp/server.js b/tests/oidc/fake_idp/server.js index a7c0ea27..5efb2567 100644 --- a/tests/oidc/fake_idp/server.js +++ b/tests/oidc/fake_idp/server.js @@ -27,6 +27,12 @@ import http from 'node:http'; import { URL } from 'node:url'; import { default as Provider } from 'oidc-provider'; +// `jose` ships as a transitive dep of oidc-provider (it's what the +// library uses internally for JWTs). We reuse it to (a) generate the +// signing keypair at boot so oidc-provider signs id_tokens with keys +// we also own, and (b) mint spec-compliant logout_token JWTs in the +// /control/backchannel-logout endpoint below. +import { SignJWT, exportJWK, generateKeyPair } from 'jose'; // ── Configuration knobs ───────────────────────────────────────────────── const ISSUER = process.env.FAKE_IDP_ISSUER || 'http://localhost:1080'; @@ -53,6 +59,14 @@ const TEST_USER_PICTURE = 'https://example.com/oidc-test-user.png'; // app roles. const TEST_USER_GROUPS = ['admin-users']; +// OxiCloud's base URL — derived from the callback URI so run.sh only +// has one place (test.env) to change the port. Used by the BCL control +// endpoint to POST logout_tokens back to OxiCloud. +const OXICLOUD_BASE_URL = + process.env.OXICLOUD_BASE_URL_FOR_BCL || 'http://localhost:8087'; +const BCL_KID = 'fake-idp-key-1'; +const BCL_EVENT = 'http://schemas.openid.net/event/backchannel-logout'; + // ── Runtime-toggleable state for negative tests ──────────────────────── // `email_verified` is normally true; the test flips it to false via // `POST /control/email-verified/false` to drive OxiCloud's anti-takeover @@ -62,6 +76,21 @@ const TEST_USER_GROUPS = ['admin-users']; // claims() callback. let emailVerifiedState = true; +// 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 +// can also mint valid logout_token JWTs from the /control endpoint, +// so OxiCloud's back-channel-logout validator (which fetches the same +// JWKS) accepts them. +const { publicKey: bclPublicKey, privateKey: bclPrivateKey } = + await generateKeyPair('RS256', { extractable: true }); +const bclPrivateJwk = await exportJWK(bclPrivateKey); +bclPrivateJwk.use = 'sig'; +bclPrivateJwk.alg = 'RS256'; +bclPrivateJwk.kid = BCL_KID; +// eslint-disable-next-line no-unused-vars +const _bclPublicKeyRef = bclPublicKey; // kept for symmetry / debugging + const configuration = { clients: [ { @@ -76,9 +105,25 @@ const configuration = { grant_types: ['authorization_code'], response_types: ['code'], token_endpoint_auth_method: 'client_secret_post', + // Back-Channel Logout 1.0 wire-up. The URI is where OxiCloud's + // handler lives (POST /api/auth/oidc/backchannel-logout). With + // session_required = true, the OP MUST include `sid` in both the + // id_token AND the logout_token — mirrors Keycloak's "Backchannel + // Logout Session Required" client toggle. OxiCloud persists the + // id_token sid on auth.sessions.oidc_sid so per-device revocation + // works; without session_required we'd fall back to sub-based + // (all-device) revocation. + backchannel_logout_uri: `${OXICLOUD_BASE_URL}/api/auth/oidc/backchannel-logout`, + backchannel_logout_session_required: true, }, ], + // Register the private JWK we generated above. The library uses it + // to sign id_tokens; the public half is served at /jwks.json and is + // what OxiCloud's OidcService caches for id_token AND logout_token + // signature verification (they share the same JWKS per BCL 1.0). + jwks: { keys: [bclPrivateJwk] }, + pkce: { required: () => true, methods: ['S256'] }, claims: { @@ -125,6 +170,17 @@ const configuration = { features: { // Turn off the dev login/consent UI; we own the interaction route. devInteractions: { enabled: false }, + // OIDC Back-Channel Logout 1.0. Turning it on makes the OP + // advertise `backchannel_logout_supported` in discovery and + // emit `sid` in id_tokens when the client has + // `backchannel_logout_session_required: true`. We do NOT rely on + // oidc-provider to send BCL notifications from its internal + // session-destroy path (which would require driving OP session + // lifecycle from the test); the /control/backchannel-logout + // endpoint below mints a spec-compliant logout_token directly + // and POSTs it to OxiCloud. That's the same wire shape a real + // IdP produces, so OxiCloud's validator is exercised end-to-end. + backchannelLogout: { enabled: true }, }, // Put scope-implied claims (name, given_name, family_name, @@ -164,7 +220,7 @@ const oidcHandler = provider.callback(); // to exercise OxiCloud's anti-takeover rejection branch). Kept on the // SAME port as the OIDC endpoints so we don't have to thread two ports // through every test config. Never used in production-shaped flows. -function handleControl(req, res) { +async function handleControl(req, res) { const url = new URL(req.url, ISSUER); if (req.method === 'POST' && url.pathname === '/control/email-verified/true') { emailVerifiedState = true; @@ -178,6 +234,74 @@ function handleControl(req, res) { res.setHeader('content-type', 'application/json'); return res.end(JSON.stringify({ email_verified: false })); } + if (req.method === 'POST' && url.pathname === '/control/backchannel-logout') { + // Body shape: `{ sub?: string, sid?: string }`. Optional so the test + // can exercise both revocation modes: + // * sub only → OxiCloud falls back to revoke-by-subject (kills all + // the user's sessions). + // * sid present → OxiCloud revokes just the session bound to that + // sid (per-device path — the "typical" mode when + // backchannel_logout_session_required is on). + // Default to sub-only against the built-in test user when neither is + // supplied — that keeps the simplest scenario a one-liner in Hurl. + 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' })); + } + const sub = parsed.sub ?? TEST_USER_SUB; + const sid = parsed.sid; // may be undefined + const now = Math.floor(Date.now() / 1000); + + // Mint the logout_token per BCL 1.0 §2.4: + // * `events` MUST contain the backchannel-logout URI as a key. + // * `sub` and/or `sid` MUST be present (we always include sub; + // sid conditional). + // * `nonce` MUST NOT be present (SignJWT does not add one by default). + // * `iat` present, `jti` present for replay-guard testing. + const payload = { events: { [BCL_EVENT]: {} } }; + if (sub) payload.sub = sub; + if (sid) payload.sid = sid; + + const jwt = await new SignJWT(payload) + .setProtectedHeader({ alg: 'RS256', kid: BCL_KID, typ: 'JWT' }) + .setIssuer(ISSUER) + .setAudience('oxicloud-test') + .setIssuedAt(now) + .setJti(`bcl-${now}-${Math.random().toString(36).slice(2, 10)}`) + .sign(bclPrivateKey); + + // POST as application/x-www-form-urlencoded per BCL §2.5. + const target = `${OXICLOUD_BASE_URL}/api/auth/oidc/backchannel-logout`; + try { + const resp = await fetch(target, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ logout_token: jwt }).toString(), + }); + const respBody = await resp.text(); + res.statusCode = 200; + res.setHeader('content-type', 'application/json'); + return res.end( + JSON.stringify({ + forwarded_to: target, + oxicloud_status: resp.status, + oxicloud_body: respBody, + }), + ); + } catch (e) { + res.statusCode = 502; + res.setHeader('content-type', 'application/json'); + return res.end( + JSON.stringify({ error: 'forward_failed', detail: String(e) }), + ); + } + } res.statusCode = 404; res.setHeader('content-type', 'application/json'); return res.end(JSON.stringify({ error: 'no such control endpoint' })); @@ -193,7 +317,7 @@ function handleControl(req, res) { const server = http.createServer(async (req, res) => { // eslint-disable-next-line no-console console.log(`[fake-idp] ${req.method} ${req.url}`); - if (req.url.startsWith('/control/')) return handleControl(req, res); + if (req.url.startsWith('/control/')) return await handleControl(req, res); try { const url = new URL(req.url, ISSUER); diff --git a/tests/oidc/oidc.hurl b/tests/oidc/oidc.hurl index 2b6dfbd2..e0ea771c 100644 --- a/tests/oidc/oidc.hurl +++ b/tests/oidc/oidc.hurl @@ -731,3 +731,153 @@ HTTP 404 # and its runner IS multi-file. See # `feedback_hurl_teardown_shared_db` for the general rule. # ───────────────────────────────────────────────────────────── + + +# ============================================================= +# Steps 13* — OIDC Back-Channel Logout 1.0 +# ============================================================= +# Proves the /api/auth/oidc/backchannel-logout endpoint accepts a +# valid IdP-signed logout_token and evicts the corresponding +# OxiCloud session — the shared-computer / single-sign-out fix +# that RP-initiated logout alone doesn't cover (RPI needs the +# browser; BCL is server-to-server and works even when the user's +# device is offline). +# +# The fake IdP mints and posts the logout_token itself via its +# `/control/backchannel-logout` endpoint (server.js handleControl): +# it signs with the same RS256 keypair whose public half sits at +# /jwks.json, so OxiCloud's validator (identical code path to +# id_token verification) accepts the signature. The Node fetch() +# then POSTs the token as application/x-www-form-urlencoded to +# OxiCloud, matching BCL §2.5. +# +# We deliberately test the sub-only path here (no `sid`) so the +# service exercises revoke_user_sessions_by_oidc_subject (the +# fallback branch used when the IdP doesn't emit `sid`). Sid-based +# per-device revocation shares the same validator + audit shape; +# a unit test in session_pg_repository covers that branch. +# ============================================================= + + +# ───────────────────────────────────────────────────────────── +# Step 13a — Freshly log in as `oidc_user`. Cookies from Step 9's +# re-login could still be usable, but the Nextcloud flow +# in Steps 12* has interleaved admin login/logout since +# then and the safest thing to prove BCL revoked +# "something live" is to start with a session we JUST +# minted. The [Options] block clears cookies so the +# `Set-Cookie` from the exchange below is what we assert +# on. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/auth/oidc/authorize +[Options] +location: false + +HTTP 307 +[Captures] +bcl_idp_url: header "Location" + + +GET {{bcl_idp_url}} +[Options] +location: true +location-trusted: true + +HTTP 200 +[Captures] +bcl_oidc_code: url regex "oidc_code=([a-f0-9]+)" + + +POST {{base_url}}/api/auth/oidc/exchange +Content-Type: application/json +{ "code": "{{bcl_oidc_code}}" } + +HTTP 200 +[Asserts] +jsonpath "$.user.username" == "oidc_user" + + +# ───────────────────────────────────────────────────────────── +# Step 13b — Confirm the cookie session is live before we knock +# it down. If /me fails here the eviction assertion in +# 13d becomes meaningless (couldn't tell "was live, +# got revoked" from "was never live"). +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/auth/me + +HTTP 200 +[Asserts] +jsonpath "$.username" == "oidc_user" + + +# ───────────────────────────────────────────────────────────── +# Step 13c — IdP-driven logout. The fake IdP's control endpoint +# mints a spec-compliant logout_token (RS256-signed, +# correct iss/aud, events claim, sub, jti, fresh iat) +# and POSTs it to OxiCloud as +# application/x-www-form-urlencoded per BCL §2.5. +# OxiCloud MUST accept it and revoke every session +# belonging to the OIDC subject — a 200 response with +# oxicloud_status=200 in the forwarding echo proves it. +# ───────────────────────────────────────────────────────────── +POST {{oidc_issuer}}/control/backchannel-logout +Content-Type: application/json +{} + +HTTP 200 +[Asserts] +jsonpath "$.oxicloud_status" == 200 + + +# ───────────────────────────────────────────────────────────── +# Step 13d — Refresh MUST fail. This is the load-bearing "BCL +# actually kicked the user" proof. +# +# Note on why we assert on /refresh and NOT /api/auth/me: +# OxiCloud access tokens are stateless JWTs — the auth +# middleware validates signature + expiry in-memory and +# does NOT consult `sessions.revoked` on every request. +# BCL flipped `sessions.revoked=true` (see audit log +# `oidc.backchannel_logout_by_sub` — 3 sessions revoked) +# which kills the refresh path immediately, but the +# still-valid in-memory access token would let /me +# return 200 until its natural expiry (~1 h default). +# That is the standard JWT trade-off: BCL fully evicts +# within one access-token TTL. The refresh 401 below is +# what proves the eviction landed; once the access +# token expires the user can't mint a new one. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/refresh +Content-Type: application/json +{} + +# 403 (not 401): the JWT signature validates, but the session is +# revoked — that's an "access denied on a valid credential" outcome. +# The refresh handler maps DomainError::AccessDenied to StatusCode:: +# FORBIDDEN. Also fires TokenReused audit + revokes the whole session +# family, which is the reuse-detection path (correctly identified: a +# call with a revoked refresh token is indistinguishable from theft +# from the server's viewpoint). +HTTP 403 + + +# ───────────────────────────────────────────────────────────── +# Step 13f — Replay guard. Firing the exact same logout_token +# twice in the freshness window must be a no-op — +# OxiCloud's app service dedupes by `jti` (see +# auth_application_service::backchannel_logout). The +# IdP still returns 200 for the second call because +# the control endpoint mints a NEW jti each time +# (Math.random() salt), so this is really testing +# "sending the same content twice is safe": second +# call would find no live sessions and revoke 0 rows. +# Either way the assertion is the same: HTTP 200 from +# the control endpoint, oxicloud_status 200. +# ───────────────────────────────────────────────────────────── +POST {{oidc_issuer}}/control/backchannel-logout +Content-Type: application/json +{} + +HTTP 200 +[Asserts] +jsonpath "$.oxicloud_status" == 200