test(oidc): test OIDC login and refresh flow
- ensure Oxicloud s correctly functional with OIDC login
- refresh token works
- givenname, familyname, picture are imported in users' informations
- test relogin (server is not using same path)
- test email verified
- test admin group provided from IdP
This commit is contained in:
@@ -298,7 +298,7 @@ jobs:
|
||||
retention-days: 1
|
||||
|
||||
api-test:
|
||||
name: API & Webdav tests
|
||||
name: API, WebDAV & OIDC tests
|
||||
needs: build
|
||||
if: github.event_name == 'pull_request'
|
||||
timeout-minutes: 30
|
||||
@@ -331,16 +331,37 @@ jobs:
|
||||
tar -xzf "xq_${XQ_VERSION}_linux_amd64.tar.gz" xq
|
||||
sudo install -m 0755 xq /usr/local/bin/xq
|
||||
|
||||
# Node for the OIDC fake IdP (tests/oidc/fake_idp/server.js — a
|
||||
# panva/node-oidc-provider wrapper). Pinned to match the version
|
||||
# used elsewhere in this workflow (frontend Playwright job uses
|
||||
# 26.3.0 too).
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 26.3.0
|
||||
cache: npm
|
||||
cache-dependency-path: tests/oidc/fake_idp/package-lock.json
|
||||
|
||||
- name: Run Hurl API tests
|
||||
run: bash tests/api/run.sh
|
||||
env:
|
||||
BUILD_TARGET: release
|
||||
|
||||
- name: Run Webdav tests
|
||||
- name: Run WebDAV tests
|
||||
run: bash tests/webdav/run.sh
|
||||
env:
|
||||
BUILD_TARGET: release
|
||||
|
||||
# OIDC integration: drives the SPA's SSO flow end-to-end against
|
||||
# the fake IdP (auto-approve login + consent, real PKCE/JWT
|
||||
# round-trip) and asserts the d1bbe8ba contract — OIDC callback
|
||||
# MUST redirect to `/login?oidc_code=…`, not `/?oidc_code=…`.
|
||||
# That bug shipped to users in production once already; the
|
||||
# assertion at tests/oidc/oidc.hurl:Step 4 is its guard.
|
||||
- name: Run OIDC tests
|
||||
run: bash tests/oidc/run.sh
|
||||
env:
|
||||
BUILD_TARGET: release
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
|
||||
@@ -157,10 +157,24 @@ front-design:
|
||||
node scripts/check-brand-drift.mjs
|
||||
|
||||
|
||||
# Hurl API functional tests (starts postgres + server, tears down after)
|
||||
# Hurl-driven functional tests (starts postgres + server, tears down after).
|
||||
#
|
||||
# Three runners — each isolated, brings up its own sidecars + server config:
|
||||
# * tests/api/run.sh — REST API surface, default server.env
|
||||
# * tests/webdav/run.sh — native WebDAV + NextCloud DAV, default server.env
|
||||
# * tests/oidc/run.sh — OIDC SSO end-to-end against a fake IdP
|
||||
# (tests/oidc/fake_idp, a Node panva/oidc-provider
|
||||
# wrapper); server launched with
|
||||
# --config server-with-oidc.env so the api and
|
||||
# webdav suites stay on the OIDC-off config.
|
||||
#
|
||||
# Same chain runs in CI under the `api-test` job in
|
||||
# .github/workflows/ci.yml; keep the order in sync so a local pass means
|
||||
# CI passes.
|
||||
api-test:
|
||||
bash tests/api/run.sh
|
||||
bash tests/webdav/run.sh
|
||||
bash tests/oidc/run.sh
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SvelteKit frontend (frontend/) — the only frontend. These `fe-*` recipes
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# OxiCloud test-server env file for the OIDC integration test.
|
||||
#
|
||||
# Layered on top of server.env: identical to the default test-server
|
||||
# config EXCEPT the OIDC client is enabled and pointed at the fake
|
||||
# IdP under tests/oidc/fake_idp/ (a panva/node-oidc-provider wrapper
|
||||
# started by tests/oidc/run.sh).
|
||||
#
|
||||
# Run pattern (used by tests/oidc/run.sh):
|
||||
# bash tests/common/spawn-db.sh
|
||||
# node tests/oidc/fake_idp/server.js & # auto-approve OIDC IdP on :1080
|
||||
# ./target/debug/oxicloud --config tests/common/server-with-oidc.env
|
||||
#
|
||||
# `--config` makes the binary read THIS file verbatim — there is no
|
||||
# auto-merge with server.env, so every variable the server needs has
|
||||
# to be repeated here. Keeping the duplication explicit beats a sourcing
|
||||
# scheme: dotenvy doesn't follow `source` directives, and the matrix of
|
||||
# "which env file is in effect" is easier to read when each one is
|
||||
# self-contained.
|
||||
|
||||
# ── Shared test config (mirrors server.env) ────────────────────────────────
|
||||
DATABASE_URL=postgres://oxicloud_test:oxicloud_test@localhost:5433/oxicloud_test
|
||||
OXICLOUD_DB_CONNECTION_STRING=postgres://oxicloud_test:oxicloud_test@localhost:5433/oxicloud_test
|
||||
OXICLOUD_STATIC_PATH=./static
|
||||
OXICLOUD_JWT_SECRET=test-secret-do-not-use-in-prod-minimum-32-chars
|
||||
OXICLOUD_ENABLE_AUTH=true
|
||||
OXICLOUD_ENABLE_TRASH=true
|
||||
OXICLOUD_ENABLE_SEARCH=true
|
||||
OXICLOUD_ENABLE_FILE_SHARING=true
|
||||
OXICLOUD_ENABLE_MUSIC=true
|
||||
OXICLOUD_EXPOSE_SYSTEM_USERS=true
|
||||
OXICLOUD_WOPI_ENABLED=false
|
||||
OXICLOUD_NEXTCLOUD_ENABLED=true
|
||||
OXICLOUD_ENABLE_ADMIN_INTERNAL_ENDPOINTS=true
|
||||
|
||||
RUST_LOG="warn,audit=info,oxicloud::infrastructure::services::oidc_service=info,oxicloud::application::services::auth_application_service=info"
|
||||
|
||||
OXICLOUD_RATE_LIMIT_REFRESH_MAX=3600
|
||||
OXICLOUD_RATE_LIMIT_LOGIN_MAX=3600
|
||||
OXICLOUD_RATE_LIMIT_REGISTER_MAX=3600
|
||||
OXICLOUD_TRUST_PROXY_CIDR=0.0.0.0/0
|
||||
|
||||
# ── OIDC client wired at the fake-idp sidecar ──────────────────────────────
|
||||
# tests/oidc/fake_idp/server.js (panva/node-oidc-provider) publishes the
|
||||
# issuer at the root URL; discovery is at /.well-known/openid-configuration
|
||||
# under it. Update the `clients[0].client_id` field there in tandem if you
|
||||
# rename the client.
|
||||
OXICLOUD_OIDC_ENABLED=true
|
||||
OXICLOUD_OIDC_ISSUER_URL=http://localhost:1080
|
||||
OXICLOUD_OIDC_CLIENT_ID=oxicloud-test
|
||||
OXICLOUD_OIDC_CLIENT_SECRET=test-client-secret-not-used-in-prod
|
||||
# The IdP redirects back to this exact URL after auto-approving; must
|
||||
# match the OxiCloud server's actual host + port (port 8087 from
|
||||
# tests/api/test.env's base_url).
|
||||
OXICLOUD_OIDC_REDIRECT_URI=http://localhost:8087/api/auth/oidc/callback
|
||||
OXICLOUD_OIDC_SCOPES="openid profile email"
|
||||
# Frontend redirect target after a successful callback. Tracks the
|
||||
# d1bbe8ba fix: the backend appends `/login?oidc_code=…` to this base,
|
||||
# so the value here is the SPA origin only.
|
||||
OXICLOUD_OIDC_FRONTEND_URL=http://localhost:8087
|
||||
OXICLOUD_OIDC_AUTO_PROVISION=true
|
||||
OXICLOUD_OIDC_PROVIDER_NAME=MockSSO
|
||||
# Group-to-role mapping. The fake IdP emits `groups: ["admin-users"]`
|
||||
# in every id_token; with this env set, the JIT-provisioning code in
|
||||
# auth_application_service.rs intersects the claim against this list
|
||||
# and promotes the new user from `user` to `admin` on a non-empty
|
||||
# match. This is the standard Authentik/Keycloak/Entra pattern: an
|
||||
# IdP group becomes an OxiCloud role.
|
||||
OXICLOUD_OIDC_ADMIN_GROUPS=admin-users
|
||||
@@ -0,0 +1 @@
|
||||
node_modules/
|
||||
Generated
+1059
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "fake-idp",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Spec-compliant OIDC stub used by tests/oidc/oidc.hurl. Wraps panva/node-oidc-provider with an auto-approve interaction so Hurl can drive the authorize → token flow without rendering a login form.",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@koa/router": "^13.1.0",
|
||||
"koa": "^2.16.0",
|
||||
"oidc-provider": "^9.4.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
// Fake OpenID Connect Identity Provider for the OxiCloud OIDC
|
||||
// integration test (tests/oidc/oidc.hurl).
|
||||
//
|
||||
// Wraps panva/node-oidc-provider — a spec-compliant OP — with a
|
||||
// minimal Node http front-end that auto-resolves every interaction
|
||||
// (login and consent) for a hard-coded test user. We don't wrap with
|
||||
// our own Koa instance because oidc-provider ships a bundled Koa that
|
||||
// the response prototype-checks against; layering another Koa around
|
||||
// it triggers `vary: res argument is required` on the first request.
|
||||
//
|
||||
// What we get from the library that we'd otherwise hand-roll:
|
||||
// * Discovery (.well-known/openid-configuration)
|
||||
// * JWKS endpoint + RS256-signed JWTs
|
||||
// * PKCE S256 verification
|
||||
// * Authorization code lifecycle
|
||||
// * Refresh token + id_token + access_token shapes
|
||||
//
|
||||
// What we get FOR FREE when we later add coverage for:
|
||||
// * Back-channel logout — flip features.backchannelLogout.enabled
|
||||
// * RP-initiated logout — flip features.rpInitiatedLogout.enabled
|
||||
// * Token revocation (RFC 7009) — flip features.revocation.enabled
|
||||
// * Token introspection (RFC 7662) — flip features.introspection.enabled
|
||||
//
|
||||
// Each future OIDC feature is a config flag in this file rather than
|
||||
// new Rust protocol code to maintain.
|
||||
|
||||
import http from 'node:http';
|
||||
import { URL } from 'node:url';
|
||||
import { default as Provider } from 'oidc-provider';
|
||||
|
||||
// ── Configuration knobs ─────────────────────────────────────────────────
|
||||
const ISSUER = process.env.FAKE_IDP_ISSUER || 'http://localhost:1080';
|
||||
const PORT = parseInt(process.env.FAKE_IDP_PORT || '1080', 10);
|
||||
const TEST_USER_SUB = 'oidc-test-user';
|
||||
const TEST_USER_USERNAME = 'oidc_user';
|
||||
const TEST_USER_EMAIL = 'oidc@example.com';
|
||||
// Full claim set pinned to deterministic values so the Hurl test can
|
||||
// assert that JIT provisioning (auth_application_service.rs:2257)
|
||||
// stores each one verbatim. Keep the claim names matching the OIDC
|
||||
// `IdTokenClaims` struct in src/infrastructure/services/oidc_service.rs.
|
||||
const TEST_USER_NAME = 'OIDC Test User';
|
||||
const TEST_USER_GIVEN_NAME = 'OIDC';
|
||||
const TEST_USER_FAMILY_NAME = 'Test';
|
||||
// `picture` is the OIDC claim; OxiCloud persists it as `User.image`
|
||||
// (a URL or data URI). We use a stable HTTP URL so a simple equality
|
||||
// check works in the Hurl assertion.
|
||||
const TEST_USER_PICTURE = 'https://example.com/oidc-test-user.png';
|
||||
// Group claim — paired with OXICLOUD_OIDC_ADMIN_GROUPS=admin-users in
|
||||
// server-with-oidc.env. The JIT path intersects this list against the
|
||||
// configured admin groups; a non-empty intersection escalates the new
|
||||
// user's role from `user` to `admin`. This is the typical SSO pattern
|
||||
// every Authentik/Keycloak/Entra deployment uses to map IdP groups to
|
||||
// app roles.
|
||||
const TEST_USER_GROUPS = ['admin-users'];
|
||||
|
||||
// ── 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
|
||||
// rejection branch (auth_application_service.rs: only `email_verified`
|
||||
// callers reach JIT-provisioning), then flips back. Module-level state
|
||||
// because oidc-provider doesn't pass test-specific context into the
|
||||
// claims() callback.
|
||||
let emailVerifiedState = true;
|
||||
|
||||
const configuration = {
|
||||
clients: [
|
||||
{
|
||||
client_id: 'oxicloud-test',
|
||||
client_secret: 'test-client-secret-not-used-in-prod',
|
||||
redirect_uris: ['http://localhost:8087/api/auth/oidc/callback'],
|
||||
grant_types: ['authorization_code'],
|
||||
response_types: ['code'],
|
||||
token_endpoint_auth_method: 'client_secret_post',
|
||||
},
|
||||
],
|
||||
|
||||
pkce: { required: () => true, methods: ['S256'] },
|
||||
|
||||
claims: {
|
||||
openid: ['sub'],
|
||||
email: ['email', 'email_verified'],
|
||||
// `profile` is the standard scope OxiCloud requests
|
||||
// (OXICLOUD_OIDC_SCOPES in server-with-oidc.env). It covers every
|
||||
// claim the JIT-provisioning code in auth_application_service.rs
|
||||
// reads except email — name + given/family + picture +
|
||||
// preferred_username + groups all ride here.
|
||||
profile: [
|
||||
'name',
|
||||
'given_name',
|
||||
'family_name',
|
||||
'preferred_username',
|
||||
'picture',
|
||||
'groups',
|
||||
],
|
||||
},
|
||||
|
||||
async findAccount(_ctx, sub) {
|
||||
if (sub !== TEST_USER_SUB) return undefined;
|
||||
return {
|
||||
accountId: sub,
|
||||
// Return EVERY claim the OIDC client could ask for. The provider
|
||||
// filters by the consented scope before issuing — values not in
|
||||
// a granted scope are dropped from the ID token / userinfo.
|
||||
async claims() {
|
||||
return {
|
||||
sub: TEST_USER_SUB,
|
||||
email: TEST_USER_EMAIL,
|
||||
email_verified: emailVerifiedState,
|
||||
name: TEST_USER_NAME,
|
||||
given_name: TEST_USER_GIVEN_NAME,
|
||||
family_name: TEST_USER_FAMILY_NAME,
|
||||
preferred_username: TEST_USER_USERNAME,
|
||||
picture: TEST_USER_PICTURE,
|
||||
groups: TEST_USER_GROUPS,
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
features: {
|
||||
// Turn off the dev login/consent UI; we own the interaction route.
|
||||
devInteractions: { enabled: false },
|
||||
},
|
||||
|
||||
// Put scope-implied claims (name, given_name, family_name,
|
||||
// preferred_username, picture, email, …) directly into the ID token
|
||||
// instead of keeping them at /userinfo only.
|
||||
//
|
||||
// OxiCloud's OIDC client (auth_application_service.rs:2085) only
|
||||
// calls /userinfo when the ID token lacks `email` — with the email
|
||||
// scope granted the ID token DOES carry email, so userinfo never
|
||||
// runs, and the default (conformIdTokenClaims: true) means `picture`
|
||||
// would silently vanish during JIT provisioning. Setting this to
|
||||
// `false` mirrors what most real-world IdPs (Authentik, Keycloak's
|
||||
// default profile) do for browser SSO clients.
|
||||
conformIdTokenClaims: false,
|
||||
|
||||
// Point every interaction at our auto-resolver below.
|
||||
interactions: {
|
||||
url(_ctx, interaction) {
|
||||
return `/auto/${interaction.uid}`;
|
||||
},
|
||||
},
|
||||
|
||||
cookies: {
|
||||
keys: ['fake-idp-cookie-key-not-a-real-secret'],
|
||||
},
|
||||
};
|
||||
|
||||
const provider = new Provider(ISSUER, configuration);
|
||||
provider.proxy = false;
|
||||
|
||||
// `provider.callback()` is an http-compatible request handler.
|
||||
// We intercept /auto/<uid> ourselves and forward everything else.
|
||||
const oidcHandler = provider.callback();
|
||||
|
||||
// `/control/*` paths are test-only hooks the Hurl suite uses to
|
||||
// flip IdP-side state between flows (e.g. force email_verified=false
|
||||
// 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) {
|
||||
const url = new URL(req.url, ISSUER);
|
||||
if (req.method === 'POST' && url.pathname === '/control/email-verified/true') {
|
||||
emailVerifiedState = true;
|
||||
res.statusCode = 200;
|
||||
res.setHeader('content-type', 'application/json');
|
||||
return res.end(JSON.stringify({ email_verified: true }));
|
||||
}
|
||||
if (req.method === 'POST' && url.pathname === '/control/email-verified/false') {
|
||||
emailVerifiedState = false;
|
||||
res.statusCode = 200;
|
||||
res.setHeader('content-type', 'application/json');
|
||||
return res.end(JSON.stringify({ email_verified: false }));
|
||||
}
|
||||
res.statusCode = 404;
|
||||
res.setHeader('content-type', 'application/json');
|
||||
return res.end(JSON.stringify({ error: 'no such control endpoint' }));
|
||||
}
|
||||
|
||||
// One-line per-request log — useful when a future test fails
|
||||
// mysteriously ("did OxiCloud actually call /me?" / "is the
|
||||
// /authorize redirect hitting the right URL?"). Kept because it's
|
||||
// low-noise and makes the next debugging session 10x easier; the
|
||||
// payload-dumping diagnostics that helped land the
|
||||
// `image`-missing-from-INSERT fix (UserPgRepository::create_user)
|
||||
// have been stripped.
|
||||
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);
|
||||
|
||||
try {
|
||||
const url = new URL(req.url, ISSUER);
|
||||
const autoMatch = url.pathname.match(/^\/auto\/[^/]+\/?$/);
|
||||
|
||||
if (autoMatch) {
|
||||
return await handleAuto(req, res);
|
||||
}
|
||||
|
||||
return oidcHandler(req, res);
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[fake-idp] unhandled error:', e);
|
||||
if (!res.headersSent) {
|
||||
res.statusCode = 500;
|
||||
res.setHeader('content-type', 'application/json');
|
||||
res.end(JSON.stringify({ error: 'internal', detail: String(e) }));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ── Auto-approve handler ───────────────────────────────────────────────
|
||||
// The library redirects /authorize to /auto/<uid>. We pull the
|
||||
// interaction state, sign the test user in (prompt=login), then grant
|
||||
// every requested claim+scope (prompt=consent). The provider issues
|
||||
// the authorization code and 302s back to OxiCloud's callback.
|
||||
async function handleAuto(req, res) {
|
||||
const details = await provider.interactionDetails(req, res);
|
||||
const {
|
||||
prompt: { name },
|
||||
params,
|
||||
} = details;
|
||||
|
||||
if (name === 'login') {
|
||||
return provider.interactionFinished(
|
||||
req,
|
||||
res,
|
||||
{ login: { accountId: TEST_USER_SUB } },
|
||||
{ mergeWithLastSubmission: false },
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'consent') {
|
||||
const grant = new provider.Grant({
|
||||
accountId: TEST_USER_SUB,
|
||||
clientId: params.client_id,
|
||||
});
|
||||
if (params.scope) grant.addOIDCScope(params.scope);
|
||||
// Explicitly grant every profile claim OxiCloud reads at JIT
|
||||
// provisioning (see src/application/services/auth_application_service.rs
|
||||
// around line 2257). `addOIDCClaims` is additive to whatever the
|
||||
// scope already implies, so listing them here is belt-and-braces
|
||||
// for keeping the claim set complete.
|
||||
grant.addOIDCClaims([
|
||||
'email',
|
||||
'email_verified',
|
||||
'name',
|
||||
'given_name',
|
||||
'family_name',
|
||||
'preferred_username',
|
||||
'picture',
|
||||
]);
|
||||
const grantId = await grant.save();
|
||||
return provider.interactionFinished(
|
||||
req,
|
||||
res,
|
||||
{ consent: { grantId } },
|
||||
{ mergeWithLastSubmission: true },
|
||||
);
|
||||
}
|
||||
|
||||
res.statusCode = 400;
|
||||
res.setHeader('content-type', 'application/json');
|
||||
res.end(JSON.stringify({ error: 'unsupported_prompt', prompt: name }));
|
||||
}
|
||||
|
||||
server.listen(PORT, () => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[fake-idp] listening on ${ISSUER} (test user sub=${TEST_USER_SUB})`);
|
||||
});
|
||||
@@ -0,0 +1,404 @@
|
||||
# =============================================================
|
||||
# OxiCloud — OIDC happy-path integration test
|
||||
# =============================================================
|
||||
# Drives the full SSO flow against the fake IdP under
|
||||
# tests/oidc/fake_idp/ (panva/node-oidc-provider with an auto-approve
|
||||
# interaction handler) and asserts every contract the SPA depends on.
|
||||
# Pinned regression: commit d1bbe8ba changed the callback's frontend
|
||||
# redirect from `/?oidc_code=…` to `/login?oidc_code=…` — Step 5's
|
||||
# `Location matches "^…/login\?oidc_code=…"` is the assertion that
|
||||
# would have caught that bug before users hit it.
|
||||
#
|
||||
# Flow walked manually (no auto-follow) so each handler is asserted
|
||||
# independently:
|
||||
#
|
||||
# 1. Bootstrap: create the local admin (provider list works
|
||||
# regardless of admin presence, but the rest of the test
|
||||
# lives more comfortably with a fully-initialised server).
|
||||
# 2. GET /api/auth/oidc/providers — SPA reads this to render
|
||||
# the SSO button.
|
||||
# 3. GET /api/auth/oidc/authorize — server mints state + PKCE,
|
||||
# redirects to the IdP.
|
||||
# 4. GET <IdP>/auth (+ full redirect chain) — fake-idp
|
||||
# auto-approves login + consent, OxiCloud's callback
|
||||
# JIT-provisions the user and redirects to
|
||||
# {frontend_url}/login?oidc_code=… The final 404 (no SPA
|
||||
# shell in test config) IS the test signal: we assert on
|
||||
# the URL we landed at, which is the d1bbe8ba contract.
|
||||
# 5. POST /api/auth/oidc/exchange — SPA swaps the one-time
|
||||
# code for tokens + cookies.
|
||||
# 6. GET /api/auth/me — proves the cookie session is live AND
|
||||
# that every OIDC profile claim (name → username, given_name,
|
||||
# family_name, picture → image, email, groups → admin role)
|
||||
# was JIT-provisioned correctly into the local user record.
|
||||
# 7. POST /api/auth/refresh — rotation of all three cookies;
|
||||
# proves the SPA's session-renewal path works on top of
|
||||
# an OIDC-provisioned account.
|
||||
# 8. GET /api/auth/me — the refreshed cookies authenticate too.
|
||||
# 9. Existing-user re-login — a second OIDC flow with the same
|
||||
# `sub` resolves to the same local user, not a duplicate.
|
||||
# 10. Anti-takeover — an unverified-email callback is rejected.
|
||||
# 11. POST /api/auth/oidc/exchange — replay-protection: the
|
||||
# one-time code is single-use, second exchange returns 401.
|
||||
# =============================================================
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 1 — Create the local admin
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/setup
|
||||
Content-Type: application/json
|
||||
{
|
||||
"username": "{{username}}",
|
||||
"email": "{{email}}",
|
||||
"password": "{{password}}"
|
||||
}
|
||||
|
||||
HTTP 201
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 2 — Provider discovery for the SPA
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
GET {{base_url}}/api/auth/oidc/providers
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$.enabled" == true
|
||||
# tests/common/server-with-oidc.env sets OXICLOUD_OIDC_PROVIDER_NAME=MockSSO.
|
||||
jsonpath "$.provider_name" == "MockSSO"
|
||||
jsonpath "$.authorize_endpoint" == "/api/auth/oidc/authorize"
|
||||
jsonpath "$.password_login_enabled" == true
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 3 — SPA-initiated authorize. Server returns a 302 with
|
||||
# state + PKCE challenge in the Location URL.
|
||||
# [Options] location: false keeps Hurl from following
|
||||
# the redirect so we can capture the target intact.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
GET {{base_url}}/api/auth/oidc/authorize
|
||||
[Options]
|
||||
location: false
|
||||
|
||||
# 307 (not 302): handler uses axum Redirect::temporary, which preserves
|
||||
# the request method on follow. For a GET-initiated SSO flow it makes
|
||||
# no practical difference, but the assertion has to match what's emitted.
|
||||
HTTP 307
|
||||
[Captures]
|
||||
idp_url: header "Location"
|
||||
[Asserts]
|
||||
# panva/node-oidc-provider publishes authorize at /auth (not
|
||||
# /authorize). The OXICLOUD_OIDC_ISSUER_URL points at the issuer
|
||||
# root; the discovery doc tells OxiCloud the actual endpoint.
|
||||
header "Location" matches "^{{oidc_authorize_endpoint}}\\?"
|
||||
header "Location" contains "state="
|
||||
header "Location" contains "code_challenge="
|
||||
header "Location" contains "code_challenge_method=S256"
|
||||
header "Location" contains "client_id=oxicloud-test"
|
||||
header "Location" contains "redirect_uri="
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 4 — Walk the entire IdP + OxiCloud redirect chain.
|
||||
#
|
||||
# The fake IdP's auto-approve handler resolves login +
|
||||
# consent silently and 302s back to OxiCloud's callback;
|
||||
# the callback validates state + exchanges code with the
|
||||
# IdP, JIT-provisions the user, then 307s the browser to
|
||||
# {frontend_url}/login?oidc_code=…
|
||||
#
|
||||
# With `location: true` Hurl follows the whole chain and
|
||||
# lands on the SPA login URL. The test server config
|
||||
# (server-with-oidc.env) points `OXICLOUD_STATIC_PATH`
|
||||
# at ./static — which is the legacy vanilla frontend, NOT
|
||||
# static-dist/ — so /login returns 404. That 404 is the
|
||||
# test signal: it proves we landed AT /login (i.e. the
|
||||
# d1bbe8ba contract held). The URL we end at is the
|
||||
# actual assertion.
|
||||
#
|
||||
# A pre-d1bbe8ba server would have redirected to
|
||||
# `http://localhost:8087/?oidc_code=…` instead — same
|
||||
# 404, but the `landed_at` assertion would catch it.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
GET {{idp_url}}
|
||||
[Options]
|
||||
location: true
|
||||
location-trusted: true
|
||||
|
||||
HTTP 404
|
||||
[Captures]
|
||||
landed_at: url
|
||||
oidc_code: url regex "oidc_code=([a-f0-9]+)"
|
||||
[Asserts]
|
||||
# The d1bbe8ba regression guard. The exact contract the SvelteKit
|
||||
# SPA depends on — `/login`, not `/`.
|
||||
variable "landed_at" matches "^http://localhost:8087/login\\?oidc_code=[a-f0-9]+$"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 5 — Swap the one-time code for a session.
|
||||
# Response sets the HttpOnly auth cookies + the
|
||||
# double-submit CSRF cookie the SPA reads to populate
|
||||
# X-CSRF-Token on subsequent mutating requests.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/auth/oidc/exchange
|
||||
Content-Type: application/json
|
||||
{ "code": "{{oidc_code}}" }
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
oidc_session_user: jsonpath "$.user.username"
|
||||
# Snapshotted so Step 7's refresh can prove the tokens rotated
|
||||
# rather than being re-issued unchanged. The refresh handler in
|
||||
# auth_handler.rs always rotates all three cookies (access JWT,
|
||||
# refresh UUID, CSRF UUID); a regression that silently keeps the
|
||||
# old refresh token would let a leaked refresh credential live
|
||||
# forever — exactly the kind of issue token-family rotation exists
|
||||
# to prevent.
|
||||
initial_access_token: jsonpath "$.access_token"
|
||||
initial_refresh_token: jsonpath "$.refresh_token"
|
||||
initial_csrf_token: cookie "oxicloud_csrf"
|
||||
[Asserts]
|
||||
jsonpath "$.user.username" == "oidc_user"
|
||||
jsonpath "$.user.email" == "oidc@example.com"
|
||||
jsonpath "$.access_token" isString
|
||||
# Multiple Set-Cookie headers come back as a list of values, so
|
||||
# `contains` only matches whole-element strings. Each cookie shows up
|
||||
# as its own list entry; we use `cookie "<name>"` (Hurl's dedicated
|
||||
# helper) which finds the cookie by name across all Set-Cookie headers.
|
||||
cookie "oxicloud_access" exists
|
||||
cookie "oxicloud_refresh" exists
|
||||
cookie "oxicloud_csrf" exists
|
||||
cookie "oxicloud_access[HttpOnly]" exists
|
||||
cookie "oxicloud_refresh[HttpOnly]" exists
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 6 — Same-jar follow-up GET proves the cookie session
|
||||
# actually authenticates. Hurl reuses the cookie jar
|
||||
# across requests in one file by default, so the
|
||||
# Set-Cookie from Step 5 carries forward.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
GET {{base_url}}/api/auth/me
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
# Stash the user id for the re-login check in Step 10 below — a
|
||||
# second OIDC flow with the same `sub` must resolve back to this
|
||||
# exact user, not silently create a duplicate.
|
||||
oidc_user_id: jsonpath "$.id"
|
||||
[Asserts]
|
||||
jsonpath "$.username" == "oidc_user"
|
||||
jsonpath "$.email" == "oidc@example.com"
|
||||
# `auth_provider` stores the OIDC provider's display name (set via
|
||||
# OXICLOUD_OIDC_PROVIDER_NAME in tests/common/server-with-oidc.env),
|
||||
# NOT a generic "oidc" tag. A locally registered admin would have
|
||||
# this field as something like "local". The distinct value here is
|
||||
# what proves JIT provisioning landed via OIDC, not setup.hurl.
|
||||
jsonpath "$.auth_provider" == "MockSSO"
|
||||
# Full claim round-trip — the fake IdP (tests/oidc/fake_idp/server.js)
|
||||
# pins these values and OxiCloud must persist each one verbatim during
|
||||
# JIT provisioning (see auth_application_service.rs around line 2257).
|
||||
# A regression that drops, swaps, or truncates a claim trips here.
|
||||
# Note the field name flip on the API side: OIDC `picture` becomes
|
||||
# UserDto.image (a URL or data URI).
|
||||
jsonpath "$.given_name" == "OIDC"
|
||||
jsonpath "$.family_name" == "Test"
|
||||
jsonpath "$.image" == "https://example.com/oidc-test-user.png"
|
||||
# Group-to-role mapping. server-with-oidc.env sets
|
||||
# OXICLOUD_OIDC_ADMIN_GROUPS=admin-users; the fake IdP's claims include
|
||||
# `groups: ["admin-users"]`. The JIT path intersects the claim against
|
||||
# the env and promotes the new user from `user` to `admin`. A
|
||||
# regression here would silently strip (or wrongly grant) admin rights
|
||||
# for every SSO deployment that uses group-based role mapping.
|
||||
jsonpath "$.role" == "admin"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 7 — Refresh-token rotation.
|
||||
#
|
||||
# POST /api/auth/refresh reads the refresh token from
|
||||
# the HttpOnly oxicloud_refresh cookie (the browser flow
|
||||
# OxiCloud's SPA uses; the JSON body shape is only a
|
||||
# backwards-compat path for non-browser clients) and
|
||||
# re-issues all three cookies. Token-family rotation:
|
||||
# the prior refresh token is invalidated server-side
|
||||
# and a reuse attempt would be caught as a theft signal.
|
||||
#
|
||||
# CSRF middleware fires here because we have a cookie
|
||||
# session — we pass the captured oxicloud_csrf value as
|
||||
# the double-submit X-CSRF-Token header, matching what
|
||||
# the SvelteKit SPA does via getCsrfHeaders().
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/auth/refresh
|
||||
X-CSRF-Token: {{initial_csrf_token}}
|
||||
Content-Type: application/json
|
||||
{}
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
refreshed_access_token: jsonpath "$.access_token"
|
||||
refreshed_refresh_token: jsonpath "$.refresh_token"
|
||||
[Asserts]
|
||||
jsonpath "$.user.username" == "oidc_user"
|
||||
jsonpath "$.access_token" isString
|
||||
jsonpath "$.refresh_token" isString
|
||||
# All three cookies must rotate. If any value were re-used, a
|
||||
# regression in cookie_auth::append_auth_cookies (or in the
|
||||
# RefreshToken use case) would silently leave the old credential
|
||||
# live — exactly the kind of bug that motivates rotation.
|
||||
variable "refreshed_access_token" != "{{initial_access_token}}"
|
||||
variable "refreshed_refresh_token" != "{{initial_refresh_token}}"
|
||||
cookie "oxicloud_access" exists
|
||||
cookie "oxicloud_refresh" exists
|
||||
cookie "oxicloud_csrf" exists
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 8 — The refreshed cookies authenticate too. Belt-and-braces:
|
||||
# rotation is only useful if the new tokens actually work.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
GET {{base_url}}/api/auth/me
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$.username" == "oidc_user"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 9 — Existing-user re-login. A second pass through the same
|
||||
# OIDC `sub` MUST resolve back to the SAME local user
|
||||
# (`oidc_user_id` captured in Step 6) — silently creating
|
||||
# a duplicate account on every login would be the
|
||||
# regression. Exercises the existing-user branch in
|
||||
# auth_application_service.rs around line 2157, distinct
|
||||
# from the JIT-provisioning branch the earlier steps hit.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
GET {{base_url}}/api/auth/oidc/authorize
|
||||
[Options]
|
||||
location: false
|
||||
|
||||
HTTP 307
|
||||
[Captures]
|
||||
relogin_idp_url: header "Location"
|
||||
|
||||
|
||||
GET {{relogin_idp_url}}
|
||||
[Options]
|
||||
location: true
|
||||
location-trusted: true
|
||||
|
||||
HTTP 404
|
||||
[Captures]
|
||||
relogin_oidc_code: url regex "oidc_code=([a-f0-9]+)"
|
||||
[Asserts]
|
||||
variable "landed_at" matches "^http://localhost:8087/login\\?oidc_code=[a-f0-9]+$"
|
||||
|
||||
|
||||
POST {{base_url}}/api/auth/oidc/exchange
|
||||
Content-Type: application/json
|
||||
{ "code": "{{relogin_oidc_code}}" }
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
# Same local id — proves the existing-user resolver matched on `sub`
|
||||
# (or `oidc_provider + oidc_subject`) instead of minting a new row.
|
||||
jsonpath "$.user.id" == "{{oidc_user_id}}"
|
||||
jsonpath "$.user.username" == "oidc_user"
|
||||
# Role from the prior JIT-provisioned admin survives the re-login.
|
||||
# Two regressions this catches: (a) the existing-user branch wiping
|
||||
# the role to a default `user`; (b) the existing-user branch
|
||||
# re-evaluating groups but missing the admin-group claim (the fake
|
||||
# IdP still emits `groups: ["admin-users"]`, OXICLOUD_OIDC_ADMIN_GROUPS
|
||||
# still resolves to "admin"). Either way, the role should remain
|
||||
# `admin` — otherwise we have a silent admin demotion on every login.
|
||||
jsonpath "$.user.role" == "admin"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 10 — Anti-takeover: an OIDC callback whose `email_verified`
|
||||
# claim is `false` MUST be rejected. Without this guard
|
||||
# an attacker who can set `email` to a victim's address
|
||||
# in their own IdP account (some IdPs allow unverified
|
||||
# emails through the consent screen) gets the victim's
|
||||
# OxiCloud account on first login.
|
||||
#
|
||||
# We flip the fake IdP into the unverified-email mode
|
||||
# via the `/control/email-verified/false` test hook,
|
||||
# drive a fresh authorize, expect the OxiCloud callback
|
||||
# to fail, then reset the IdP for any future steps.
|
||||
#
|
||||
# This SHOULD use a different `sub` than the existing
|
||||
# verified user to exercise the JIT path (the
|
||||
# anti-takeover check fires there), but the auto-approve
|
||||
# handler resolves one fixed `sub`. The check still
|
||||
# fires on the existing user path too because the
|
||||
# verified-email requirement is evaluated on every
|
||||
# callback — that's what we exercise here.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST http://localhost:1080/control/email-verified/false
|
||||
|
||||
HTTP 200
|
||||
|
||||
|
||||
GET {{base_url}}/api/auth/oidc/authorize
|
||||
[Options]
|
||||
location: false
|
||||
|
||||
HTTP 307
|
||||
[Captures]
|
||||
unverified_idp_url: header "Location"
|
||||
|
||||
|
||||
GET {{unverified_idp_url}}
|
||||
[Options]
|
||||
location: true
|
||||
location-trusted: true
|
||||
|
||||
# OxiCloud's callback returns 403 (or 401, depending on which
|
||||
# branch fires). What matters is the final URL is NOT
|
||||
# /login?oidc_code= — a successful login would have landed there
|
||||
# regardless of status, so a status-code-only assertion would
|
||||
# miss a "we accidentally provisioned the unverified user"
|
||||
# regression. We assert on BOTH the status AND the negation of
|
||||
# the success URL via Hurl's built-in `url` query (NOT the
|
||||
# `landed_at` capture from Step 4 — that variable is stale here).
|
||||
HTTP *
|
||||
[Asserts]
|
||||
status >= 400
|
||||
status < 500
|
||||
url not matches "^http://localhost:8087/login\\?oidc_code="
|
||||
|
||||
|
||||
# Reset the IdP so this test doesn't poison anything that runs
|
||||
# after it (defensive — there's nothing after right now, but a
|
||||
# future test would silently fail with "all my users get
|
||||
# rejected" if we forgot this).
|
||||
POST http://localhost:1080/control/email-verified/true
|
||||
|
||||
HTTP 200
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 11 — Replay protection: the one-time code is rejected on a
|
||||
# second attempt. Defense-in-depth check.
|
||||
#
|
||||
# Hurl 4.x has no per-request cookie-jar clear, so this
|
||||
# request still carries the session cookies set in Step 5.
|
||||
# That means CSRF middleware rejects the unauthenticated
|
||||
# (no X-CSRF-Token header) POST with 403 BEFORE the OIDC
|
||||
# single-use-code check runs. Both are valid replay
|
||||
# defenses; in a real attack the attacker has the code but
|
||||
# not the session cookie, in which case the rejection
|
||||
# would come from the OIDC layer as 401.
|
||||
#
|
||||
# The single-use-code path itself is covered by unit
|
||||
# tests in auth_application_service.rs (the
|
||||
# completed_oidc_logins moka cache + remove-on-use).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/auth/oidc/exchange
|
||||
Content-Type: application/json
|
||||
{ "code": "{{oidc_code}}" }
|
||||
|
||||
HTTP 403
|
||||
Executable
+173
@@ -0,0 +1,173 @@
|
||||
#!/usr/bin/env bash
|
||||
# OIDC integration-test runner.
|
||||
#
|
||||
# Brings up the test DB, a Node-based fake IdP (panva/node-oidc-provider
|
||||
# under tests/oidc/fake_idp/), and OxiCloud configured to talk to that
|
||||
# IdP, then runs the Hurl suite and tears everything down.
|
||||
#
|
||||
# Why a separate runner from tests/api/run.sh:
|
||||
# * the OxiCloud server here is launched with
|
||||
# `--config tests/common/server-with-oidc.env` (OIDC enabled) — the
|
||||
# default api run uses server.env with OIDC off, and we don't want
|
||||
# to flip flags mid-suite;
|
||||
# * the IdP is a Node process this script owns, distinct from the
|
||||
# postgres-test container that lives in spawn-db.sh.
|
||||
#
|
||||
# Invocation:
|
||||
# * locally: chained from `just api-test` after the api + webdav
|
||||
# suites, or directly via `bash tests/oidc/run.sh`
|
||||
# * in CI: chained from the `api-test` job in
|
||||
# .github/workflows/ci.yml — same shell call, same env.
|
||||
#
|
||||
# Prerequisites: docker, cargo, node ≥ 20, npm, hurl ≥ 4.0.
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
COMMON="$REPO_ROOT/tests/common"
|
||||
OIDC_DIR="$REPO_ROOT/tests/oidc"
|
||||
FAKE_IDP_DIR="$OIDC_DIR/fake_idp"
|
||||
|
||||
# Test variables (base_url, admin creds, oidc_issuer, oidc_authorize_endpoint).
|
||||
# shellcheck source=test.env
|
||||
source "$OIDC_DIR/test.env"
|
||||
|
||||
SERVER_PORT="${base_url##*:}"
|
||||
# Derive the IdP port from oidc_issuer the same way (e.g. localhost:1080 → 1080).
|
||||
# Keeps run.sh and test.env in lockstep — change one, the other follows.
|
||||
IDP_PORT="${oidc_issuer##*:}"
|
||||
|
||||
# ── Helpers ────────────────────────────────────────────────────────────────
|
||||
log() { echo "[oidc-test] $*"; }
|
||||
die() { echo "[oidc-test] ERROR: $*" >&2; exit 1; }
|
||||
|
||||
wait_for_http() {
|
||||
local url="$1" timeout="${2:-60}"
|
||||
local deadline=$(( $(date +%s) + timeout ))
|
||||
until curl -sf "$url" >/dev/null 2>&1; do
|
||||
[[ $(date +%s) -ge $deadline ]] && die "Timeout waiting for $url"
|
||||
sleep 0.5
|
||||
done
|
||||
}
|
||||
|
||||
# ── Fake-IdP process management ────────────────────────────────────────────
|
||||
# All cleanup paths funnel through this helper so an exit at ANY phase
|
||||
# (early failure during npm install, hurl assertion fail, Ctrl-C, …)
|
||||
# always reaps the node process. The earlier subshell+setsid pattern
|
||||
# leaked daemons whenever the subshell exited before the trap fired,
|
||||
# leading to the "no change after rerunning" failure mode: an old
|
||||
# fake-idp from a prior failed run was still bound to port 1080, so
|
||||
# the new spawn either failed silently (EADDRINUSE) or the tests hit
|
||||
# the stale config.
|
||||
#
|
||||
# Belt-and-braces orphan reaping: kill by script-path pattern AND by
|
||||
# port. The pattern match misses processes started from a different
|
||||
# absolute path (e.g. via a symlinked checkout, or from a `node
|
||||
# server.js` started from inside tests/oidc/fake_idp/ where the
|
||||
# command line is just `node server.js`). The port-based fallback
|
||||
# catches anything bound to 1080 regardless of how it was launched —
|
||||
# the original case that caused the "stale daemon" debugging session.
|
||||
kill_fake_idp() {
|
||||
pkill -f "tests/oidc/fake_idp/server.js" 2>/dev/null || true
|
||||
pkill -f "node.*server.js" 2>/dev/null || true
|
||||
if command -v lsof >/dev/null 2>&1; then
|
||||
local pids
|
||||
pids=$(lsof -ti :"$IDP_PORT" 2>/dev/null || true)
|
||||
if [[ -n "$pids" ]]; then
|
||||
# shellcheck disable=SC2086
|
||||
kill -9 $pids 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Teardown (always runs on exit) ─────────────────────────────────────────
|
||||
SERVER_PID=""
|
||||
|
||||
cleanup() {
|
||||
if [[ -n "$SERVER_PID" ]]; then
|
||||
log "Stopping OxiCloud server (pid $SERVER_PID)..."
|
||||
kill "$SERVER_PID" 2>/dev/null || true
|
||||
wait "$SERVER_PID" 2>/dev/null || true
|
||||
fi
|
||||
log "Stopping fake-idp..."
|
||||
kill_fake_idp
|
||||
bash "$COMMON/stop-db.sh" || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# ── 1. Postgres ────────────────────────────────────────────────────────────
|
||||
bash "$COMMON/spawn-db.sh"
|
||||
|
||||
# ── 2. Fake IdP (Node) ─────────────────────────────────────────────────────
|
||||
log "Installing fake-idp dependencies..."
|
||||
if [[ -f "$FAKE_IDP_DIR/package-lock.json" ]]; then
|
||||
# `npm ci` is faster + deterministic when the lockfile is present.
|
||||
(cd "$FAKE_IDP_DIR" && npm ci --silent --no-audit --no-fund)
|
||||
else
|
||||
(cd "$FAKE_IDP_DIR" && npm install --silent --no-audit --no-fund)
|
||||
fi
|
||||
|
||||
# Sweep any orphaned fake-idp processes from a prior crashed/aborted
|
||||
# run BEFORE starting the new one. Without this, a stale daemon
|
||||
# bound to port 1080 would either swallow new spawns silently
|
||||
# (EADDRINUSE buried in /tmp/fake-idp.log) or serve every request
|
||||
# with its old config — producing the maddening "I changed the
|
||||
# config but nothing changed" failure mode.
|
||||
log "Sweeping any orphan fake-idp processes from prior runs..."
|
||||
kill_fake_idp
|
||||
# Brief moment for the OS to actually release the listener; without
|
||||
# this the new node call can race the just-killed process and lose
|
||||
# the bind.
|
||||
sleep 0.3
|
||||
|
||||
log "Starting fake-idp on port $IDP_PORT..."
|
||||
# Background the node process directly (no setsid / subshell wrapper).
|
||||
# pkill-by-path in cleanup means we don't need a process-group dance
|
||||
# to reap the child; the simpler launch keeps $IDP_PID correct (it's
|
||||
# the actual node PID, not a wrapping subshell) for any future call
|
||||
# site that wants to wait on it.
|
||||
FAKE_IDP_ISSUER="$oidc_issuer" FAKE_IDP_PORT="$IDP_PORT" \
|
||||
node "$FAKE_IDP_DIR/server.js" > /tmp/fake-idp.log 2>&1 &
|
||||
log "Waiting for fake-idp discovery endpoint..."
|
||||
wait_for_http "$oidc_issuer/.well-known/openid-configuration" 30
|
||||
log "fake-idp is ready (logs: /tmp/fake-idp.log)"
|
||||
|
||||
# ── 3. Load shared server env (port + storage path) ────────────────────────
|
||||
set -a
|
||||
# shellcheck source=../common/server-with-oidc.env
|
||||
source "$COMMON/server-with-oidc.env"
|
||||
OXICLOUD_SERVER_PORT=$SERVER_PORT
|
||||
OXICLOUD_STORAGE_PATH="$REPO_ROOT/tests/oidc/storage"
|
||||
set +a
|
||||
|
||||
# shellcheck source=../common/wipe-storage.sh
|
||||
source "$COMMON/wipe-storage.sh"
|
||||
wipe_storage "$OXICLOUD_STORAGE_PATH"
|
||||
|
||||
# ── 4. Start OxiCloud server with OIDC enabled ─────────────────────────────
|
||||
BUILD_TARGET="${BUILD_TARGET:-debug}"
|
||||
OXICLOUD_BIN="$REPO_ROOT/target/$BUILD_TARGET/oxicloud"
|
||||
|
||||
if [[ ! -x "$OXICLOUD_BIN" ]]; then
|
||||
log "Building OxiCloud server ($BUILD_TARGET)..."
|
||||
case "$BUILD_TARGET" in
|
||||
debug) (cd "$REPO_ROOT" && cargo build 2>&1 | tail -n 20) || die "cargo build failed" ;;
|
||||
release) (cd "$REPO_ROOT" && cargo build --release 2>&1 | tail -n 20) || die "cargo build --release failed" ;;
|
||||
*) die "Unsupported BUILD_TARGET='$BUILD_TARGET' (expected 'debug' or 'release')" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
log "Starting OxiCloud server with OIDC config on port $SERVER_PORT..."
|
||||
"$OXICLOUD_BIN" --config "$COMMON/server-with-oidc.env" &
|
||||
SERVER_PID=$!
|
||||
log "Waiting for server at $base_url..."
|
||||
wait_for_http "$base_url/ready" 120
|
||||
log "Server is ready."
|
||||
|
||||
# ── 5. Run the OIDC Hurl suite ─────────────────────────────────────────────
|
||||
log "Running OIDC Hurl tests..."
|
||||
hurl --variables-file "$OIDC_DIR/test.env" \
|
||||
--file-root "$REPO_ROOT/tests" \
|
||||
--test --jobs 1 \
|
||||
"$OIDC_DIR/oidc.hurl"
|
||||
|
||||
log "OIDC tests passed."
|
||||
@@ -0,0 +1,12 @@
|
||||
# Variables fed to Hurl for the OIDC integration tests.
|
||||
# Mirror tests/api/test.env so the same admin-setup flow works on top
|
||||
# of the OIDC-enabled server binary.
|
||||
base_url=http://localhost:8087
|
||||
username=admin
|
||||
email=admin@example.com
|
||||
# gitguardian:ignore
|
||||
password=TestPassword1!
|
||||
# Discovery / authorize endpoints exposed by tests/oidc/fake_idp.
|
||||
# oidc-provider publishes authorize at /auth (not /authorize) by default.
|
||||
oidc_issuer=http://localhost:1080
|
||||
oidc_authorize_endpoint=http://localhost:1080/auth
|
||||
Reference in New Issue
Block a user