Commit Graph

5 Commits

Author SHA1 Message Date
Paul Nothaft 219d07b04a feat(auth): OIDC logout-to-IdP — phase 3 (#798) (#865)
* feat(auth): OIDC logout-to-IdP — phase 3 (#798)

RP-initiated logout behind a new oidc_logout_from_idp setting: logging
out of PicPeak also ends the IdP session. The SSO callback stores the
raw ID token in an HttpOnly cookie (also the marker that the session
came in via SSO — local-password sessions never bounce to the IdP);
/logout builds the end_session URL from discovery metadata with
id_token_hint + post_logout_redirect_uri + client_id and returns it as
ssoLogoutUrl for the frontend to navigate to. Any failure (no
end_session_endpoint, IdP unreachable, feature off) degrades to the
plain local logout.

Settings surface exposes the toggle plus the computed post-logout
redirect URI to register at the IdP. Session timeouts deliberately stay
local-only.

6 integration tests over the mock IdP; live-verified against
Keycloak 26 (logout ends the Keycloak session, no confirmation prompt).

* fix(auth): harden the SSO logout marker cookie (#798 phase 3)

Codex review round 1:
- Derive the oidc_id_token cookie options from the shared cookie policy
  (COOKIE_SAMESITE / COOKIE_DOMAIN / secure resolution) — hardcoded Lax
  meant split-origin deployments running on SameSite=None never sent the
  marker to the cross-site /logout XHR, silently disabling logout-to-IdP.
- Oversized ID tokens (>3.9KB) now store a bare 'sso' marker instead of
  no cookie, so the claimed client_id-only end-session fallback actually
  happens; /logout only passes the value as id_token_hint when it is a
  real JWT.
- establishAdminSession clears any stale marker on every fresh login —
  sessions can die without /logout (deactivation, expiry, restore), and
  a surviving marker would bounce a later local-password session to the
  IdP. The SSO callback re-sets the marker for its own session.

Tests: oversized-token marker + hint-less end-session URL, stale-marker
cleared on local login; helper updated for the clear+set cookie pair.

* fix(auth): validate the logout hint against the current OIDC config (#798 phase 3)

Codex review round 2: an ID token stored at login can outlive an
issuer/client config change; sending it to the newly configured IdP as
id_token_hint strands the user on the IdP's error page (providers
validate iss/aud on the hint). buildEndSessionUrl now decodes the hint
(no verification — routing only): different issuer → skip the round-trip
entirely (the session belongs to another IdP); same issuer but changed
client → keep the round-trip, drop the unusable hint. Two tests pin both
paths.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-07-24 11:42:09 +02:00
Paul Nothaft 7f7d38a57f fix(oidc): security + robustness hardening from codex review rounds 1-2
Round 1:
- bind SSO identities to (external_issuer, external_subject): OIDC only
  guarantees sub uniqueness within an issuer, so a sub-only lookup let a
  newly configured IdP's user inherit an old IdP's admin account on
  subject collision; migration 162 gains external_issuer + composite
  unique index (unmerged migration, edited in place)
- fetch UserInfo (with sub cross-check) when the ID token carries no
  email — spec-compliant providers may serve email/profile claims only
  there; ID-token claims win on merge
- allowlist /admin/sso/login + /callback in maintenance mode, or
  SSO-only (JIT) admins are locked out exactly when they need in
- strip reserved keys (oidc_client_secret, setup_token) from BOTH
  generic settings reads (GET / and GET /:type)

Round 2:
- redirect_uri prefers API_URL (the API's public origin — where the
  state cookie lives); final redirects absolute to the frontend base;
  login button builds its URL via buildResourceUrl — split-origin
  deployments (absolute VITE_API_URL) work end to end
- PUT /sso validates the MERGED resulting state (partial update cannot
  blank issuer/client while enabled=true survives; enabling requires a
  derivable redirect URI)
- openid scope forced into oidc_scopes on save
- discovery-cache key includes a secret fingerprint (multi-worker
  secret rotation)
- email→admin linking claims the row atomically (conditional update on
  external_subject IS NULL) — concurrent first-time callbacks with the
  same verified email but different subjects can't both authenticate

Tests: mock IdP gains userinfo endpoint + email-via-userinfo-only mode;
new cases pin the userinfo merge and issuer-collision non-inheritance;
redirect assertions updated for absolute URLs. 13/13.
2026-07-16 09:41:48 +02:00
Paul Nothaft ed5fc5ad5c feat(auth): OIDC SSO for admin users — phase 1 (#798)
Authorization-code + PKCE against a single configurable IdP via
openid-client v5, with JIT provisioning. Verified end-to-end against a
real Keycloak 26 (realm + confidential client + verified-email user):
settings → discovery test → login button → Keycloak → dashboard.

Backend:
- migration 162: admin_users.auth_provider ('local' default) +
  external_subject, composite unique index
- oidcService: settings-driven config (client secret AES-256-GCM at
  rest, mfaService pattern, OIDC_ENCRYPTION_KEY fallback JWT_SECRET),
  cached discovery, sub-based identity binding — email linking of
  existing admins only with email_verified=true; JIT behind
  oidc_autoprovision with configurable default role and an unusable
  random password hash
- GET /api/auth/admin/sso/login + /callback: state/nonce/PKCE verifier
  cross the redirect in a 10-min signed httpOnly SameSite=Lax cookie;
  the callback reuses the local login's session establishment
  (completeAdminLogin split into establishAdminSession + JSON wrapper)
  so SSO sessions are identical downstream; every failure lands on
  /admin/login?sso_error=<key> as a translated toast
- dedicated /admin/settings/sso GET/PUT/test endpoints (secret
  write-only, redacted to a set-flag; registered ABOVE the generic
  /:type matcher which would shadow them); oidc_client_secret added to
  the reserved keys stripped from generic settings upserts
- public settings expose only oidc_enabled + oidc_button_label for the
  login page

Frontend:
- Settings → Single Sign-On (OIDC) tab: issuer/client/secret, scopes,
  autoprovision + default role, button label, enable toggle, redirect
  URI copy box, server-side discovery test
- login page: SSO button (custom label) when enabled; sso_error query
  param surfaced as translated toasts; EN+DE i18n

Tests: 11 integration cases against an in-process mock IdP (real
discovery/JWKS/PKCE/ID-token validation) — JIT on/off, sub-vs-email
binding, unverified-email rejection, deactivated admin, missing/forged
state cookie, nonce tamper, secret encryption round-trip, disabled 404.

MFA is delegated to the IdP on the SSO path; local login stays
available as break-glass. Role-claim mapping and logout-to-IdP follow
in phase 2/3.
2026-07-16 08:54:26 +02:00
Paul Nothaft 5c4da1eacd test(crm): HTTP route tests for CRM public + admin surface (#570)
Closes #570.

PR #555 shipped the CRM module with strong service-layer coverage
but no HTTP-layer tests. This adds Supertest-based route coverage
across the externally-reachable public routes (P0) and an auth-gate
sweep of every CRM admin route (P1+P2).

## What's covered

### P0 — Public routes (49% of new tests)

The three public routes are the security-sensitive surface — any IP
with the raw token from a leaked email can hit them. Tests pin the
publicTokenGuards.loadActionToken contract end-to-end:

- **publicQuotes** (8 tests) — GET load + POST respond: 404 unknown,
  400 malformed, 410 expired, 200 valid w/ sanitised payload (no
  customer_account_id / created_by_admin_id leakage), 429 after 20
  bad attempts (IP lockout), 400 invalid action.
- **publicContracts** (10 tests) — GET load + POST sign + POST
  upload-signed-pdf + GET pdf: same guard outcomes per endpoint,
  plus the pre-multer token check (malformed token rejected before
  multer reads the body — prevents the disk-spam attack the
  preMulterTokenGuard was added for).
- **publicPaymentCheck** (6 tests) — different shape (no
  loadActionToken; service does its own validation): validator gate
  on token shape, all 4 canonical actions pass through the
  validator, negative amountMinor rejected.

The NULL-expires_at defensive branch in loadActionToken is
documented but not tested here — current schema declares
quote/contract_action_tokens.expires_at NOT NULL, so the branch is
unreachable at the route level. Worth a direct unit test on
loadActionToken if anyone wants to cover it.

### P1 + P2 — Admin routes (51% of new tests, 25 cases)

One consolidated `adminCrmAuth.test.js` file rather than nine
per-route files — the auth-gate contract is identical for every CRM
admin route, so a parametrised `describe.each` is more efficient
and lands the same coverage:

Per route (adminQuotes, adminContracts, adminInvoices, adminCalendar,
adminDeals, adminTaxReport, adminBusinessProfile):
- 401 without Authorization header (adminAuth gate)
- 401 with invalid JWT signature (adminAuth signature check)
- 2xx with super-admin token + CRM feature flags on (permission +
  feature-flag gates both pass)

Plus 4 tests for the CRM additions in adminCustomers
(hour-entries / bill / trigger-monthly-bill) — those endpoints
are mixed in with pre-existing customer routes, so they get
explicit coverage rather than bulk via the parametrised sweep.

## Harness extensions to integration/helpers/crmDb.js

Three new helpers (one place for any future route test to find):

- `mintAdminToken(adminId, opts)` — JWT signed with the test
  JWT_SECRET, shape matches what adminAuth expects.
- `createPublicToken(db, tableName, opts)` — insert a row into
  quote/contract_action_tokens with controllable expires_at /
  used_at / token. Note: Date values are explicitly ISO-stringified
  before insert — bare Date objects round-tripped inconsistently
  through knex+SQLite, sometimes via .toString() → literal
  `"[object Object]"` which parsed back to NaN and silently defeated
  the expiry guard. Caught it in test bring-up.
- `buildRouteApp(mount, router)` — minimal Express app (json + cookies)
  with a catch-all error handler that mirrors middleware/errorHandler
  (uses err.statusCode, not err.status — getting that wrong silently
  maps every 4xx to 500 in tests).
- `assignAdminRole(db, adminId, roleName)` — promotes a seedMinimal
  admin into super_admin (or any seeded role) for happy-path tests.

## Out of scope (follow-up)

Deeper integration tests for the document mint/send paths
(adminQuotes.send → PDF persisted + token minted + email queued;
adminInvoices.Storno → new row with shared deal_uuid + original
cancelled; adminContracts.countersign → integrity_hash computed)
are deferred. The service-layer behind those is already covered by
the existing __tests__/services/ suites — this PR pins the
HTTP-layer contract, which is what #570 actually asked for.

## Counts

- 4 new test files, 49 tests total
- ~860 LOC of test code + ~85 LOC of new harness in crmDb.js
- All tests pass in <2.5s (no real network, no real disk except the
  per-test tmpdir, no email sending)
2026-05-29 22:36:59 +02:00
Luca 3240137f1e test(crm): integration harness + schema-shape regression net
Adds two pieces:

- __tests__/integration/helpers/crmDb.js — boots a temp-SQLite test
  DB by invoking every migrations/core/*.up() directly. Bypasses
  knex's Migrator because its exclusive write lock deadlocks
  001_init's nested initializeDatabase() call. ~1 second cold start.

- __tests__/integration/crmSchema.test.js — 36 assertions on the
  table + column layout after the consolidated CRM migration runs.
  Pins:
    - every CRM table present (quotes, contracts, invoices + the
      eight supporting tables)
    - deal_uuid columns on all three lineage tables (the column
      DocumentLineageCard joins on — drop it anywhere and the card
      silently returns partial data)
    - back-pointer FKs (converted_contract_id, source_contract_id,
      source_quote_id) — the exact columns that triggered the
      Postgres FK-ordering bug fixed earlier in this PR
    - Storno discriminator (kind, cancels_invoice_id, replaces_
      invoice_id) per feedback_storno_filter_everywhere
    - event time columns from migration 137

A full quote→contract→invoice lineage walk is deferred — quote
service's nextQuoteNumber() opens an inner transaction from inside
the createQuote outer transaction, which deadlocks SQLite's default
1-connection pool. Postgres dev DBs never see it. Either fix the
service to thread trx through, or run lineage tests against a real
Postgres in CI (mirror schema-drift.yml). Filed as separate work.
2026-05-26 19:50:57 +02:00