test(oidc): test back-channel logout
This commit is contained in:
@@ -27,6 +27,12 @@
|
|||||||
import http from 'node:http';
|
import http from 'node:http';
|
||||||
import { URL } from 'node:url';
|
import { URL } from 'node:url';
|
||||||
import { default as Provider } from 'oidc-provider';
|
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 ─────────────────────────────────────────────────
|
// ── Configuration knobs ─────────────────────────────────────────────────
|
||||||
const ISSUER = process.env.FAKE_IDP_ISSUER || 'http://localhost:1080';
|
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.
|
// app roles.
|
||||||
const TEST_USER_GROUPS = ['admin-users'];
|
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 ────────────────────────
|
// ── Runtime-toggleable state for negative tests ────────────────────────
|
||||||
// `email_verified` is normally true; the test flips it to false via
|
// `email_verified` is normally true; the test flips it to false via
|
||||||
// `POST /control/email-verified/false` to drive OxiCloud's anti-takeover
|
// `POST /control/email-verified/false` to drive OxiCloud's anti-takeover
|
||||||
@@ -62,6 +76,21 @@ const TEST_USER_GROUPS = ['admin-users'];
|
|||||||
// claims() callback.
|
// claims() callback.
|
||||||
let emailVerifiedState = true;
|
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 = {
|
const configuration = {
|
||||||
clients: [
|
clients: [
|
||||||
{
|
{
|
||||||
@@ -76,9 +105,25 @@ const configuration = {
|
|||||||
grant_types: ['authorization_code'],
|
grant_types: ['authorization_code'],
|
||||||
response_types: ['code'],
|
response_types: ['code'],
|
||||||
token_endpoint_auth_method: 'client_secret_post',
|
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'] },
|
pkce: { required: () => true, methods: ['S256'] },
|
||||||
|
|
||||||
claims: {
|
claims: {
|
||||||
@@ -125,6 +170,17 @@ const configuration = {
|
|||||||
features: {
|
features: {
|
||||||
// Turn off the dev login/consent UI; we own the interaction route.
|
// Turn off the dev login/consent UI; we own the interaction route.
|
||||||
devInteractions: { enabled: false },
|
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,
|
// 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
|
// 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
|
// 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.
|
// 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);
|
const url = new URL(req.url, ISSUER);
|
||||||
if (req.method === 'POST' && url.pathname === '/control/email-verified/true') {
|
if (req.method === 'POST' && url.pathname === '/control/email-verified/true') {
|
||||||
emailVerifiedState = true;
|
emailVerifiedState = true;
|
||||||
@@ -178,6 +234,74 @@ 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 }));
|
||||||
}
|
}
|
||||||
|
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.statusCode = 404;
|
||||||
res.setHeader('content-type', 'application/json');
|
res.setHeader('content-type', 'application/json');
|
||||||
return res.end(JSON.stringify({ error: 'no such control endpoint' }));
|
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) => {
|
const server = http.createServer(async (req, res) => {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.log(`[fake-idp] ${req.method} ${req.url}`);
|
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 {
|
try {
|
||||||
const url = new URL(req.url, ISSUER);
|
const url = new URL(req.url, ISSUER);
|
||||||
|
|||||||
@@ -731,3 +731,153 @@ HTTP 404
|
|||||||
# and its runner IS multi-file. See
|
# and its runner IS multi-file. See
|
||||||
# `feedback_hurl_teardown_shared_db` for the general rule.
|
# `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
|
||||||
|
|||||||
Reference in New Issue
Block a user