26 Commits

Author SHA1 Message Date
hsiegeln
47c303afa0 docs(handoff): logout-hardening — server-side end-to-end verified
Drove the full revocation flow against a running cameleer-server-app jar
(temp postgres+clickhouse, env-var admin):

  GET  /auth/me  with fresh token             -> 200
  POST /auth/logout                            -> 204
  GET  /auth/me  with same revoked token       -> 401
  POST /auth/logout (unauthenticated)          -> 204
  users.token_revoked_before                   -> non-null
  audit_log (action=logout, category=AUTH)    -> 1 row, SUCCESS

Proves the full chain end-to-end: controller revokes, audit lands, and
the JwtAuthenticationFilter prefix-strip fix actually enforces revocation
against the bare users.user_id (the original bug).

Browser-driven SPA smoke is still owed — Playwright MCP allowlist in
this env blocks 8081, so the SPA flow was verified by code-inspection
during Tasks 4+5. OIDC-user smoke against Logto remains owed pending
post_logout_redirect_uri registration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 12:16:43 +02:00
hsiegeln
664acf2614 Merge feature/logout-hardening: server-side revocation + RP-Initiated Logout
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 2m50s
CI / docker (push) Successful in 2m19s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 48s
Fixes a silent token-revocation bug (JwtAuthenticationFilter was looking
up users by prefixed JWT subject instead of the bare user_id), adds
POST /api/v1/auth/logout that bumps token_revoked_before, and replaces
the broken cross-origin fetch logout in the SPA with a proper top-level
RP-Initiated Logout redirect (id_token_hint + post_logout_redirect_uri
+ client_id). Adds a signed-out splash and prompt=login defence.

Operational follow-up: SaaS team must register
<base-url>/login as a post_logout_redirect_uri on each Logto tenant
client. See docs/handoff/2026-04-27-logout-hardening.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 12:05:27 +02:00
hsiegeln
463c6348b3 docs(handoff): logout-hardening verification notes
Records the automated outcomes (4/4 ITs pass, typecheck + build green)
and lists the three manual smoke tests still required from the SaaS
team — local-user, OIDC-user against Logto, stolen-token. The OIDC test
depends on Logto-side post_logout_redirect_uri registration; the others
can be exercised against any cameleer-server deployment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 12:04:02 +02:00
hsiegeln
7837272a46 docs(handoff): SaaS-side post_logout_redirect_uri requirement
Operational note for the cameleer-saas / Logto admin team. Covers what
changed in cameleer-server (RP-Initiated Logout via top-level redirect
+ POST /auth/logout server-side revocation + signed-out splash +
prompt=login defence), what they need to register in Logto per tenant,
how to verify, and a failure-mode runbook table.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 12:00:54 +02:00
hsiegeln
2535741474 docs(rules): document POST /auth/logout on UiAuthController
Updates both UiAuthController listings (Auth flat + security/) so future
sessions know /logout exists, that it bumps token_revoked_before with a
+1ms race-safety bump, and that it audits under AuditCategory.AUTH.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 12:00:05 +02:00
hsiegeln
32c8786d06 feat(ui): signed-out splash + prompt=login on OIDC redirect
Two defensive layers complementing the RP-Initiated Logout in 82e25933:

1. cameleer:signed_out sessionStorage flag (set in auth-store.logout,
   read+cleared in LoginPage on mount) renders a 'You have been signed
   out successfully' card with an explicit 'Sign in again' button.
   Mirrors the cameleer-saas pattern.

2. prompt=login on the OIDC authorization redirect forces the IdP to
   re-prompt for credentials even if its session cookie somehow
   survived RP-Initiated Logout (proxy, race, misconfigured
   post_logout_redirect_uri). OIDC Core 1.0 §3.1.2.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 11:59:04 +02:00
hsiegeln
82e2593332 fix(ui): proper OIDC logout — server revoke + top-level redirect
Previous logout fired fetch(end_session, {mode:'no-cors'}), which is a
no-op for OIDC: cross-origin fetch never clears the IdP's session cookie.
Result: subsequent SSO clicks silently re-authenticated the prior user.

New flow:
1. Best-effort POST /auth/logout to bump token_revoked_before.
2. Clear localStorage + Zustand state.
3. Set sessionStorage 'cameleer:signed_out=1' so /login renders a
   confirmation splash (mirrors cameleer-saas pattern).
4. window.location.replace(end_session_endpoint?id_token_hint=...
   &post_logout_redirect_uri=...&client_id=...) — top-level navigation,
   the only form that actually clears the IdP session cookie.

client_id is now persisted at OIDC initiation alongside
end_session_endpoint and id_token, so logout has all three params
without an extra round-trip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 11:57:04 +02:00
hsiegeln
da3895c31d chore(ui): regenerate OpenAPI schema for /auth/logout
Picks up the new POST /api/v1/auth/logout endpoint introduced in
90315330. Generated against a locally-running build (not the remote
generate-api:live URL, which lags behind this branch).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 11:53:40 +02:00
hsiegeln
83a10de497 fix(auth): close same-ms revocation race + tidy audit cleanup
Bumps token_revoked_before by 1ms so a JWT issued in the same millisecond
as a logout call (Date.from(Instant.now()) quantises iat to ms) does not
survive the filter's strict isBefore check.

Also extends LogoutControllerIT @AfterEach to delete the audit_log row,
keeping reused Postgres containers clean for downstream ITs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 09:26:05 +02:00
hsiegeln
9031533077 feat(auth): add POST /auth/logout that revokes all user tokens
Bumps users.token_revoked_before = now() for the calling user, audited
under AuditCategory.AUTH. Best-effort: returns 204 even when the request
is unauthenticated, so the SPA can call it on every logout regardless of
token state. Token-rejection is enforced by the existing
JwtAuthenticationFilter revocation check (fixed in 7066795c).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 09:21:47 +02:00
hsiegeln
b4c6e45d35 test(auth): JwtRevocationIT cleanup + unrevoked-token coverage
Adds @AfterEach to delete the test users so Testcontainers reuse does
not leak an authenticated user with a future token_revoked_before into
the shared schema (visible to LicenseUsageReader.snapshot, user-admin
listing tests, etc.). Adds unrevokedUserTokenIsAccepted to pin the
revoked == null no-op branch as a first-class assertion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 09:18:10 +02:00
hsiegeln
7066795c3c fix(auth): strip user: prefix before token-revocation lookup
JwtAuthenticationFilter compared the JWT subject (user:alice) against
users.user_id (bare alice), so token_revoked_before was never read for
any user. Strips the prefix to match the convention documented in
CLAUDE.md. Adds JwtRevocationIT as a regression.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 09:11:55 +02:00
hsiegeln
6e4977ea3b docs(plan): logout hardening implementation plan
Tracks the work to (a) fix the silently-inert token-revocation lookup in
JwtAuthenticationFilter, (b) add POST /api/v1/auth/logout that bumps
users.token_revoked_before, and (c) replace the broken cross-origin
fetch logout in the SPA with proper RP-Initiated Logout (top-level
redirect) plus a signed-out splash and prompt=login defence.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 09:01:52 +02:00
hsiegeln
1809574fe6 ci: include cameleer-license-api in maven deploy project list
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 3m31s
CI / docker (push) Successful in 2m44s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 48s
SonarQube / sonarqube (push) Successful in 8m32s
The license-api module was added in 858975f0 but the CI deploy step's
`-pl` list still only built parent + server-core + minter. server-core
now depends on cameleer-license-api, which wasn't in the registry yet,
so the deploy job failed with:

    Could not find artifact com.cameleer:cameleer-license-api:jar:1.0-SNAPSHOT
    in gitea (https://gitea.siegeln.net/api/packages/cameleer/maven)

Add cameleer-license-api to the project list so it builds and publishes
before its consumers in the same reactor invocation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 20:41:26 +02:00
hsiegeln
858975f03f refactor(license): extract cameleer-license-api module from server-core
Some checks failed
CI / cleanup-branch (push) Has been skipped
CI / build (push) Failing after 2m57s
CI / docker (push) Has been skipped
CI / deploy (push) Has been skipped
CI / deploy-feature (push) Has been skipped
Splits the pure license contract types (LicenseInfo, LicenseValidator,
LicenseState, LicenseStateMachine, LicenseLimits, DefaultTierLimits) into a
new cameleer-license-api module under package com.cameleer.license.

Why: cameleer-license-minter previously depended on cameleer-server-core for
these types, dragging cameleer-server-core + cameleer-common onto the
classpath of every minter consumer (notably cameleer-saas). The SaaS
management plane has no business carrying server-runtime types — it only
needs the license contract to mint and verify tokens.

After:
  cameleer-license-minter -> cameleer-license-api  (no server internals)
  cameleer-server-core    -> cameleer-license-api
  cameleer-saas           -> cameleer-license-minter -> cameleer-license-api

Verified: mvn -pl cameleer-license-minter dependency:tree shows the minter
no longer pulls cameleer-server-core or cameleer-common. Full reactor
verify (-DskipITs) green: 371 tests pass.

LicenseGate stays in server-core (server-runtime state holder, not contract).

Closes cameleer/cameleer-server#156

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 20:06:52 +02:00
hsiegeln
30db609aff Merge feature/auth-harmonization: capability-driven login UX
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 3m6s
CI / docker (push) Successful in 2m49s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 53s
Replaces the prompt=none → /login?local trap with a deterministic
capability endpoint (GET /api/v1/auth/capabilities). LoginPage renders
SSO-primary or local form based on caps; ?local is the explicit
admin-recovery escape hatch. Drops prompt=none from the SSO authorize
URL per RFC 9700 §4.4. Adds Vitest + IT coverage and docs.

MFA enrollment / enforcement deferred to issue #154.
2026-04-26 19:52:31 +02:00
hsiegeln
45b5f473c9 refactor(auth): post-review tidy — drop @NotNull, refresh e2e comment, use oidc.primary
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 19:48:20 +02:00
hsiegeln
71688dea16 docs(auth): document AuthCapabilitiesController + login routing 2026-04-26 19:41:20 +02:00
hsiegeln
b63b9aa4bb fix(ui): drop OidcCallback ?local trap on login_required 2026-04-26 19:38:15 +02:00
hsiegeln
7565cdcf2f fix(ui): try/finally in handleOidcLogin; logout redirects to /login (not ?local)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 19:36:23 +02:00
hsiegeln
b7d390adf4 feat(ui): capability-driven LoginPage; drop prompt=none silent SSO
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 19:29:59 +02:00
hsiegeln
29769480be feat(ui): useAuthCapabilities hook 2026-04-26 19:23:39 +02:00
hsiegeln
657281461d chore(api): regenerate OpenAPI types for /auth/capabilities
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 19:22:14 +02:00
hsiegeln
af53eca7f6 test(auth): tighten AuthCapabilitiesControllerIT — drop redundant stub, add coverage gaps 2026-04-26 19:17:05 +02:00
hsiegeln
4f6e7ea4dc feat(auth): AuthCapabilitiesController — GET /api/v1/auth/capabilities
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 19:10:17 +02:00
96fc55b932 Merge pull request 'feature/auth-harmonization' (#155) from feature/auth-harmonization into main
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 3m58s
CI / docker (push) Successful in 37s
CI / deploy (push) Successful in 38s
CI / deploy-feature (push) Has been skipped
Reviewed-on: #155
2026-04-26 19:01:00 +02:00
64 changed files with 1964 additions and 205 deletions

View File

@@ -112,6 +112,12 @@ Env-scoped read-path controllers (`AlertController`, `AlertRuleController`, `Ale
- `DatabaseAdminController` — GET `/api/v1/admin/database/**` (conditional on `infrastructureendpoints` flag). - `DatabaseAdminController` — GET `/api/v1/admin/database/**` (conditional on `infrastructureendpoints` flag).
- `ServerMetricsAdminController``/api/v1/admin/server-metrics/**`. GET `/catalog`, GET `/instances`, POST `/query`. Generic read API over the `server_metrics` ClickHouse table so SaaS dashboards don't need direct CH access. Delegates to `ServerMetricsQueryStore` (impl `ClickHouseServerMetricsQueryStore`). Visibility matches ClickHouse/Database admin: `@ConditionalOnProperty(infrastructureendpoints, matchIfMissing=true)` + class-level `@PreAuthorize("hasRole('ADMIN')")`. Validation: metric/tag regex `^[a-zA-Z0-9._]+$`, statistic regex `^[a-z_]+$`, `to - from ≤ 31 days`, stepSeconds ∈ [10, 3600], response capped at 500 series. `IllegalArgumentException` → 400. `/query` supports `raw` + `delta` modes (delta does per-`server_instance_id` positive-clipped differences, then aggregates across instances). Derived `statistic=mean` for timers computes `sum(total|total_time)/sum(count)` per bucket. - `ServerMetricsAdminController``/api/v1/admin/server-metrics/**`. GET `/catalog`, GET `/instances`, POST `/query`. Generic read API over the `server_metrics` ClickHouse table so SaaS dashboards don't need direct CH access. Delegates to `ServerMetricsQueryStore` (impl `ClickHouseServerMetricsQueryStore`). Visibility matches ClickHouse/Database admin: `@ConditionalOnProperty(infrastructureendpoints, matchIfMissing=true)` + class-level `@PreAuthorize("hasRole('ADMIN')")`. Validation: metric/tag regex `^[a-zA-Z0-9._]+$`, statistic regex `^[a-z_]+$`, `to - from ≤ 31 days`, stepSeconds ∈ [10, 3600], response capped at 500 series. `IllegalArgumentException` → 400. `/query` supports `raw` + `delta` modes (delta does per-`server_instance_id` positive-clipped differences, then aggregates across instances). Derived `statistic=mean` for timers computes `sum(total|total_time)/sum(count)` per bucket.
### Auth (flat)
- `UiAuthController``/api/v1/auth` (login, refresh, me, logout). Local username/password against env-var admin or DB BCrypt hash. Lockout after 5 failed attempts. `POST /logout` is permitAll — controller resolves the user from the access token if present, bumps `users.token_revoked_before = now().plusMillis(1)` to invalidate all outstanding refresh + access tokens (enforced by `JwtAuthenticationFilter`), audits `AuditCategory.AUTH / logout`, returns 204. Best-effort: 204 also when called without a token so the SPA's logout never fails on already-expired sessions. The +1ms guards against same-millisecond races (JWT `iat` is ms-quantised, filter check is strict `isBefore`).
- `OidcAuthController``/api/v1/auth/oidc` (config, callback). Code → token exchange. Roles via custom JWT claim, claim mapping rules, or default roles.
- `AuthCapabilitiesController``GET /api/v1/auth/capabilities` (unauthenticated). Reports `{oidc:{enabled, providerName, primary}, localAccounts:{enabled, adminRecoveryOnly}}` so the SPA renders the login page deterministically. `oidc.primary == oidc.enabled`; `localAccounts.adminRecoveryOnly == oidc.primary`. `providerName` is best-effort label via `OidcProviderNameDeriver` (Logto / Keycloak / Auth0 / Okta / Single Sign-On). The SPA hides the local form behind `?local` when `adminRecoveryOnly` is true.
### Other (flat) ### Other (flat)
- `DetailController` — GET `/api/v1/executions/{executionId}` + processor snapshot endpoints. - `DetailController` — GET `/api/v1/executions/{executionId}` + processor snapshot endpoints.
@@ -162,7 +168,7 @@ Env-scoped read-path controllers (`AlertController`, `AlertRuleController`, `Ale
- `SecurityConfig` — WebSecurityFilterChain, JWT filter, CORS, OIDC conditional. `/api/v1/admin/outbound-connections/**` GETs permit OPERATOR in addition to ADMIN (defense-in-depth at controller level); mutations remain ADMIN-only. Alerting matchers: GET `/environments/*/alerts/**` VIEWER+; POST/PUT/DELETE rules and silences OPERATOR+; ack/read/bulk-read VIEWER+; POST `/alerts/notifications/*/retry` OPERATOR+. - `SecurityConfig` — WebSecurityFilterChain, JWT filter, CORS, OIDC conditional. `/api/v1/admin/outbound-connections/**` GETs permit OPERATOR in addition to ADMIN (defense-in-depth at controller level); mutations remain ADMIN-only. Alerting matchers: GET `/environments/*/alerts/**` VIEWER+; POST/PUT/DELETE rules and silences OPERATOR+; ack/read/bulk-read VIEWER+; POST `/alerts/notifications/*/retry` OPERATOR+.
- `JwtAuthenticationFilter` — OncePerRequestFilter, validates Bearer tokens - `JwtAuthenticationFilter` — OncePerRequestFilter, validates Bearer tokens
- `JwtServiceImpl` — HMAC-SHA256 JWT (Nimbus JOSE) - `JwtServiceImpl` — HMAC-SHA256 JWT (Nimbus JOSE)
- `UiAuthController``/api/v1/auth` (login, refresh, me). Upserts `users.user_id = request.username()` (bare); signs JWTs with `subject = "user:" + userId`. `refresh`/`me` strip the `"user:"` prefix from incoming subjects via `stripSubjectPrefix()` before any DB/RBAC lookup. - `UiAuthController``/api/v1/auth` (login, refresh, me, logout). Upserts `users.user_id = request.username()` (bare); signs JWTs with `subject = "user:" + userId`. `refresh`/`me`/`logout` strip the `"user:"` prefix from incoming subjects via `stripSubjectPrefix()` before any DB/RBAC lookup. `logout` revokes outstanding tokens by writing `users.token_revoked_before` and audits under `AuditCategory.AUTH / logout`.
- `OidcAuthController``/api/v1/auth/oidc` (login-uri, token-exchange, logout). Upserts `users.user_id = "oidc:" + oidcUser.subject()` (no `user:` prefix); signs JWTs with `subject = "user:oidc:" + oidcUser.subject()`. `applyClaimMappings` + `getSystemRoleNames` calls all use the bare `oidc:<sub>` form. - `OidcAuthController``/api/v1/auth/oidc` (login-uri, token-exchange, logout). Upserts `users.user_id = "oidc:" + oidcUser.subject()` (no `user:` prefix); signs JWTs with `subject = "user:oidc:" + oidcUser.subject()`. `applyClaimMappings` + `getSystemRoleNames` calls all use the bare `oidc:<sub>` form.
- `OidcTokenExchanger` — code -> tokens, role extraction from access_token then id_token - `OidcTokenExchanger` — code -> tokens, role extraction from access_token then id_token
- `OidcProviderHelper` — OIDC discovery, JWK source cache - `OidcProviderHelper` — OIDC discovery, JWK source cache

View File

@@ -47,14 +47,19 @@ paths:
## license/ — License domain (signed-token tier system) ## license/ — License domain (signed-token tier system)
The pure license **contract types** live in the separate `cameleer-license-api` module under package `com.cameleer.license` (no Spring, no server-runtime deps) so consumers like `cameleer-license-minter` and `cameleer-saas` can use them without inheriting server internals. Server-core only contains the runtime state holder (`LicenseGate`).
Contract types in `cameleer-license-api` (package `com.cameleer.license`):
- `LicenseInfo` — record: `(UUID licenseId, String tenantId, String label, Map<String,Integer> limits, Instant issuedAt, Instant expiresAt, int gracePeriodDays)`. `isExpired()` true once `now > expiresAt + gracePeriodDays`; `isAfterRawExpiry()` true once `now > expiresAt`. Constructed via `LicenseValidator`; canonical ctor null-checks all required fields and rejects blank tenantId / negative grace. - `LicenseInfo` — record: `(UUID licenseId, String tenantId, String label, Map<String,Integer> limits, Instant issuedAt, Instant expiresAt, int gracePeriodDays)`. `isExpired()` true once `now > expiresAt + gracePeriodDays`; `isAfterRawExpiry()` true once `now > expiresAt`. Constructed via `LicenseValidator`; canonical ctor null-checks all required fields and rejects blank tenantId / negative grace.
- `LicenseLimits` — typed limits container backed by `Map<String,Integer>`. `defaultsOnly()` returns the `DefaultTierLimits.DEFAULTS` view; `mergeOverDefaults(overrides)` produces the license-overrides UNION default tier. `get(String key)` returns the cap; throws `IllegalArgumentException` for unknown keys (programmer error). `isDefaultSourced(key, license)` reports whether a key fell through to the default tier. - `LicenseLimits` — typed limits container backed by `Map<String,Integer>`. `defaultsOnly()` returns the `DefaultTierLimits.DEFAULTS` view; `mergeOverDefaults(overrides)` produces the license-overrides UNION default tier. `get(String key)` returns the cap; throws `IllegalArgumentException` for unknown keys (programmer error). `isDefaultSourced(key, license)` reports whether a key fell through to the default tier.
- `DefaultTierLimits` — immutable `LinkedHashMap` of constants for the no-license fallback tier: `max_environments=1, max_apps=3, max_agents=5, max_users=3, max_outbound_connections=1, max_alert_rules=2, max_total_cpu_millis=2000, max_total_memory_mb=2048, max_total_replicas=5, max_execution_retention_days=1, max_log_retention_days=1, max_metric_retention_days=1, max_jar_retention_count=3`. - `DefaultTierLimits` — immutable `LinkedHashMap` of constants for the no-license fallback tier: `max_environments=1, max_apps=3, max_agents=5, max_users=3, max_outbound_connections=1, max_alert_rules=2, max_total_cpu_millis=2000, max_total_memory_mb=2048, max_total_replicas=5, max_execution_retention_days=1, max_log_retention_days=1, max_metric_retention_days=1, max_jar_retention_count=3`.
- `LicenseValidator` — verifies signed token. Constructor `(String publicKeyBase64, String expectedTenantId)` decodes an X.509 Ed25519 public key. `validate(String token)` splits `payload.signature`, verifies the Ed25519 signature, parses the JSON payload, enforces `tenantId == expectedTenantId`, and returns `LicenseInfo`. Throws `SecurityException` on signature mismatch / `IllegalArgumentException` on parse failure / expired payload. - `LicenseValidator` — verifies signed token. Constructor `(String publicKeyBase64, String expectedTenantId)` decodes an X.509 Ed25519 public key. `validate(String token)` splits `payload.signature`, verifies the Ed25519 signature, parses the JSON payload, enforces `tenantId == expectedTenantId`, and returns `LicenseInfo`. Throws `SecurityException` on signature mismatch / `IllegalArgumentException` on parse failure / expired payload.
- `LicenseGate` — runtime state holder (thread-safe via `AtomicReference<Snapshot>`). `getCurrent()` returns the current `LicenseInfo` (null when ABSENT/INVALID); `getState()` delegates to `LicenseStateMachine.classify(...)`; `getEffectiveLimits()` returns license-overrides UNION defaults in `ACTIVE`/`GRACE`, defaults-only otherwise. `getInvalidReason()`, `load(LicenseInfo)`, `markInvalid(String reason)`, `clear()` are the mutators. `getLimit(key, defaultValue)` shorthand swallows unknown-key errors.
- `LicenseStateMachine` — pure classifier. `classify(LicenseInfo, String invalidReason)` returns `INVALID` if a reason is set, `ABSENT` if no license, `ACTIVE` if `now <= expiresAt`, `GRACE` if expired but within grace window, `EXPIRED` otherwise. - `LicenseStateMachine` — pure classifier. `classify(LicenseInfo, String invalidReason)` returns `INVALID` if a reason is set, `ABSENT` if no license, `ACTIVE` if `now <= expiresAt`, `GRACE` if expired but within grace window, `EXPIRED` otherwise.
- `LicenseState` — enum: `ABSENT, ACTIVE, GRACE, EXPIRED, INVALID`. - `LicenseState` — enum: `ABSENT, ACTIVE, GRACE, EXPIRED, INVALID`.
Runtime state holder in server-core (package `com.cameleer.server.core.license`):
- `LicenseGate` — runtime state holder (thread-safe via `AtomicReference<Snapshot>`). `getCurrent()` returns the current `LicenseInfo` (null when ABSENT/INVALID); `getState()` delegates to `LicenseStateMachine.classify(...)`; `getEffectiveLimits()` returns license-overrides UNION defaults in `ACTIVE`/`GRACE`, defaults-only otherwise. `getInvalidReason()`, `load(LicenseInfo)`, `markInvalid(String reason)`, `clear()` are the mutators. `getLimit(key, defaultValue)` shorthand swallows unknown-key errors.
## search/ — Execution search and stats ## search/ — Execution search and stats
- `SearchService` — search, count, stats, statsForApp, statsForRoute, timeseries, timeseriesForApp, timeseriesForRoute, timeseriesGroupedByApp, timeseriesGroupedByRoute, slaCompliance, slaCountsByApp, slaCountsByRoute, topErrors, activeErrorTypes, punchcard, distinctAttributeKeys. `statsForRoute`/`timeseriesForRoute` take `(routeId, applicationId)` — app filter is applied to `stats_1m_route`. - `SearchService` — search, count, stats, statsForApp, statsForRoute, timeseries, timeseriesForApp, timeseriesForRoute, timeseriesGroupedByApp, timeseriesGroupedByRoute, slaCompliance, slaCountsByApp, slaCountsByRoute, topErrors, activeErrorTypes, punchcard, distinctAttributeKeys. `statsForRoute`/`timeseriesForRoute` take `(routeId, applicationId)` — app filter is applied to `stats_1m_route`.

View File

@@ -86,7 +86,7 @@ jobs:
- name: Deploy minter to Maven registry - name: Deploy minter to Maven registry
if: github.event_name == 'push' if: github.event_name == 'push'
run: mvn deploy -DskipTests -DskipITs --batch-mode -pl .,cameleer-server-core,cameleer-license-minter run: mvn deploy -DskipTests -DskipITs --batch-mode -pl .,cameleer-license-api,cameleer-server-core,cameleer-license-minter
env: env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}

View File

@@ -14,8 +14,10 @@ Cameleer Server — observability server that receives, stores, and serves Camel
## Modules ## Modules
- `cameleer-license-api` — pure license contract types (`LicenseInfo`, `LicenseValidator`, `LicenseState`, `LicenseStateMachine`, `LicenseLimits`, `DefaultTierLimits`) under package `com.cameleer.license`. No Spring or server-runtime deps; consumed by `cameleer-server-core` (validation/runtime gate) and `cameleer-license-minter` (vendor signing) — and transitively by `cameleer-saas` via the minter — without inheriting server internals.
- `cameleer-server-core` — domain logic, storage interfaces, services (no Spring dependencies) - `cameleer-server-core` — domain logic, storage interfaces, services (no Spring dependencies)
- `cameleer-server-app` — Spring Boot web app, REST controllers, SSE, persistence, Docker orchestration - `cameleer-server-app` — Spring Boot web app, REST controllers, SSE, persistence, Docker orchestration
- `cameleer-license-minter` — vendor-only Ed25519 license signing library + CLI. Depends only on `cameleer-license-api` so consumers don't pull in `cameleer-server-core`.
## Build Commands ## Build Commands
@@ -59,6 +61,7 @@ java -jar cameleer-server-app/target/cameleer-server-app-1.0-SNAPSHOT.jar
- Log processor correlation: The agent sets `cameleer.processorId` in MDC, identifying which processor node emitted a log line. - Log processor correlation: The agent sets `cameleer.processorId` in MDC, identifying which processor node emitted a log line.
- Logging: ClickHouse JDBC set to INFO (`com.clickhouse`), HTTP client to WARN (`org.apache.hc.client5`) in application.yml - Logging: ClickHouse JDBC set to INFO (`com.clickhouse`), HTTP client to WARN (`org.apache.hc.client5`) in application.yml
- Security: JWT auth with RBAC (AGENT/VIEWER/OPERATOR/ADMIN roles), Ed25519 config signing (key derived deterministically from JWT secret via HMAC-SHA256), bootstrap token for registration. CORS: `CAMELEER_SERVER_SECURITY_CORSALLOWEDORIGINS` (comma-separated) overrides `CAMELEER_SERVER_SECURITY_UIORIGIN` for multi-origin setups. Infrastructure access: `CAMELEER_SERVER_SECURITY_INFRASTRUCTUREENDPOINTS=false` disables Database and ClickHouse admin endpoints. Last-ADMIN guard: system prevents removal of the last ADMIN role (409 Conflict). Password policy: min 12 chars, 3-of-4 character classes, no username match. Brute-force protection: 5 failed attempts -> 15 min lockout. Token revocation: `token_revoked_before` column on users, checked in `JwtAuthenticationFilter`, set on password change. - Security: JWT auth with RBAC (AGENT/VIEWER/OPERATOR/ADMIN roles), Ed25519 config signing (key derived deterministically from JWT secret via HMAC-SHA256), bootstrap token for registration. CORS: `CAMELEER_SERVER_SECURITY_CORSALLOWEDORIGINS` (comma-separated) overrides `CAMELEER_SERVER_SECURITY_UIORIGIN` for multi-origin setups. Infrastructure access: `CAMELEER_SERVER_SECURITY_INFRASTRUCTUREENDPOINTS=false` disables Database and ClickHouse admin endpoints. Last-ADMIN guard: system prevents removal of the last ADMIN role (409 Conflict). Password policy: min 12 chars, 3-of-4 character classes, no username match. Brute-force protection: 5 failed attempts -> 15 min lockout. Token revocation: `token_revoked_before` column on users, checked in `JwtAuthenticationFilter`, set on password change.
- Login routing: `GET /api/v1/auth/capabilities` (unauthenticated) tells the SPA whether OIDC is the primary entry point. When OIDC is configured, the SSO button is the primary CTA and the local form is hidden behind `?local` (admin-recovery escape hatch). Per RFC 9700 §4.4 we do **not** use `prompt=none` for primary login — that returns `login_required` for first-time users and traps them on a local form.
- OIDC: Optional external identity provider support (token exchange pattern). Configured via admin API/UI, stored in database (`server_config` table). Resource server mode: accepts external access tokens (Logto M2M) via JWKS validation when `CAMELEER_SERVER_SECURITY_OIDCISSUERURI` is set. Scope-based role mapping via `SystemRole.normalizeScope()`. System roles synced on every OIDC login via `applyClaimMappings()` in `OidcAuthController` (calls `clearManagedAssignments` + `assignManagedRole` on `RbacService`) — always overwrites managed role assignments; uses managed assignment origin to avoid touching group-inherited or directly-assigned roles. Supports ES384, ES256, RS256. - OIDC: Optional external identity provider support (token exchange pattern). Configured via admin API/UI, stored in database (`server_config` table). Resource server mode: accepts external access tokens (Logto M2M) via JWKS validation when `CAMELEER_SERVER_SECURITY_OIDCISSUERURI` is set. Scope-based role mapping via `SystemRole.normalizeScope()`. System roles synced on every OIDC login via `applyClaimMappings()` in `OidcAuthController` (calls `clearManagedAssignments` + `assignManagedRole` on `RbacService`) — always overwrites managed role assignments; uses managed assignment origin to avoid touching group-inherited or directly-assigned roles. Supports ES384, ES256, RS256.
- OIDC role extraction: `OidcTokenExchanger` reads roles from the **access_token** first (JWT with `at+jwt` type), then falls back to id_token. `OidcConfig` includes `audience` (RFC 8707 resource indicator) and `additionalScopes`. All provider-specific configuration is external — no provider-specific code in the server. - OIDC role extraction: `OidcTokenExchanger` reads roles from the **access_token** first (JWT with `at+jwt` type), then falls back to id_token. `OidcConfig` includes `audience` (RFC 8707 resource indicator) and `additionalScopes`. All provider-specific configuration is external — no provider-specific code in the server.
- Sensitive keys: Global enforced baseline for masking sensitive data in agent payloads. Merge rule: `final = global UNION per-app` (case-insensitive dedup, per-app can only add, never remove global keys). - Sensitive keys: Global enforced baseline for masking sensitive data in agent payloads. Merge rule: `final = global UNION per-app` (case-insensitive dedup, per-app can only add, never remove global keys).
@@ -96,7 +99,7 @@ When adding, removing, or renaming classes, controllers, endpoints, UI component
<!-- gitnexus:start --> <!-- gitnexus:start -->
# GitNexus — Code Intelligence # GitNexus — Code Intelligence
This project is indexed by GitNexus as **cameleer-server** (9731 symbols, 24987 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. This project is indexed by GitNexus as **cameleer-server** (10530 symbols, 27383 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.

View File

@@ -0,0 +1,54 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.cameleer</groupId>
<artifactId>cameleer-server-parent</artifactId>
<version>1.0-SNAPSHOT</version>
</parent>
<artifactId>cameleer-license-api</artifactId>
<name>Cameleer License API</name>
<description>Pure license contract types — LicenseInfo, LicenseValidator, LicenseState, LicenseStateMachine, LicenseLimits, DefaultTierLimits. Shared by server-core (validation/runtime gate) and cameleer-license-minter (vendor-side signing). Has no Spring or server-runtime dependencies so consumers like cameleer-saas can depend on the minter without inheriting server internals.</description>
<dependencies>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<executions>
<!-- Plain library JAR — no repackage. -->
<execution>
<id>repackage</id>
<phase>none</phase>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

View File

@@ -1,4 +1,4 @@
package com.cameleer.server.core.license; package com.cameleer.license;
import java.util.Collections; import java.util.Collections;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;

View File

@@ -1,4 +1,4 @@
package com.cameleer.server.core.license; package com.cameleer.license;
import java.time.Instant; import java.time.Instant;
import java.util.Map; import java.util.Map;

View File

@@ -1,4 +1,4 @@
package com.cameleer.server.core.license; package com.cameleer.license;
import java.util.Collections; import java.util.Collections;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;

View File

@@ -1,4 +1,4 @@
package com.cameleer.server.core.license; package com.cameleer.license;
public enum LicenseState { public enum LicenseState {
ABSENT, ABSENT,

View File

@@ -1,4 +1,4 @@
package com.cameleer.server.core.license; package com.cameleer.license;
public final class LicenseStateMachine { public final class LicenseStateMachine {

View File

@@ -1,4 +1,4 @@
package com.cameleer.server.core.license; package com.cameleer.license;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;

View File

@@ -1,4 +1,4 @@
package com.cameleer.server.core.license; package com.cameleer.license;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;

View File

@@ -1,4 +1,4 @@
package com.cameleer.server.core.license; package com.cameleer.license;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;

View File

@@ -1,4 +1,4 @@
package com.cameleer.server.core.license; package com.cameleer.license;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;

View File

@@ -1,4 +1,4 @@
package com.cameleer.server.core.license; package com.cameleer.license;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;

View File

@@ -58,7 +58,7 @@ Two JARs land in `cameleer-license-minter/target/`:
```java ```java
import com.cameleer.license.minter.LicenseMinter; import com.cameleer.license.minter.LicenseMinter;
import com.cameleer.server.core.license.LicenseInfo; import com.cameleer.license.LicenseInfo;
LicenseInfo info = new LicenseInfo( LicenseInfo info = new LicenseInfo(
java.util.UUID.randomUUID(), java.util.UUID.randomUUID(),
@@ -136,11 +136,11 @@ base64(canonicalJson) + "." + base64(ed25519Signature)
- The signature is computed with `Signature.getInstance("Ed25519")` over the canonical payload bytes (not over the base64-encoded form). - The signature is computed with `Signature.getInstance("Ed25519")` over the canonical payload bytes (not over the base64-encoded form).
- Encoding is `Base64.getEncoder()` (RFC 4648 §4 — *not* base64url). The validator decodes with the matching `Base64.getDecoder()`. - Encoding is `Base64.getEncoder()` (RFC 4648 §4 — *not* base64url). The validator decodes with the matching `Base64.getDecoder()`.
`LicenseValidator.validate(...)` (`cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseValidator.java:42`) splits on the first `.`, decodes both halves, verifies the signature, then deserializes the payload. `LicenseValidator.validate(...)` (`cameleer-license-api/src/main/java/com/cameleer/license/LicenseValidator.java:42`) splits on the first `.`, decodes both halves, verifies the signature, then deserializes the payload.
## LicenseInfo schema ## LicenseInfo schema
Source: `cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseInfo.java`. Field-by-field: Source: `cameleer-license-api/src/main/java/com/cameleer/license/LicenseInfo.java`. Field-by-field:
| Field | Type | Required | Semantics | | Field | Type | Required | Semantics |
|---|---|---|---| |---|---|---|---|
@@ -154,7 +154,7 @@ Source: `cameleer-server-core/src/main/java/com/cameleer/server/core/license/Lic
## Limits dictionary ## Limits dictionary
Canonical key set: `cameleer-server-core/src/main/java/com/cameleer/server/core/license/DefaultTierLimits.java`. Any key not listed here is silently ignored by the server's `LicenseGate.getEffectiveLimits()`. Canonical key set: `cameleer-license-api/src/main/java/com/cameleer/license/DefaultTierLimits.java`. Any key not listed here is silently ignored by the server's `LicenseGate.getEffectiveLimits()`.
| CLI flag | Key | Default | What the server enforces | | CLI flag | Key | Default | What the server enforces |
|---|---|---|---| |---|---|---|---|
@@ -284,4 +284,4 @@ mvn -pl cameleer-server-app dependency:tree | grep license-minter
# expected: empty output (or, in development branches, a single line scoped 'test') # expected: empty output (or, in development branches, a single line scoped 'test')
``` ```
`cameleer-license-minter/pom.xml` depends on `cameleer-server-core` for `LicenseInfo` and the validator round-trip used by `--verify`. The server app intentionally does not depend on the minter — vendors mint outside the customer-deployed runtime, and a compromised customer cannot leverage server code to forge tokens. `cameleer-license-minter/pom.xml` depends on `cameleer-license-api` for the pure license contract types (`LicenseInfo`, `LicenseValidator`) used by mint + `--verify`. It deliberately does **not** depend on `cameleer-server-core`, so consumers of the minter (e.g. `cameleer-saas`) do not inherit server-runtime types onto their classpath. The server app intentionally does not depend on the minter — vendors mint outside the customer-deployed runtime, and a compromised customer cannot leverage server code to forge tokens.

View File

@@ -17,7 +17,7 @@
<dependencies> <dependencies>
<dependency> <dependency>
<groupId>com.cameleer</groupId> <groupId>com.cameleer</groupId>
<artifactId>cameleer-server-core</artifactId> <artifactId>cameleer-license-api</artifactId>
</dependency> </dependency>
<dependency> <dependency>
<groupId>com.fasterxml.jackson.core</groupId> <groupId>com.fasterxml.jackson.core</groupId>

View File

@@ -1,6 +1,6 @@
package com.cameleer.license.minter; package com.cameleer.license.minter;
import com.cameleer.server.core.license.LicenseInfo; import com.cameleer.license.LicenseInfo;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.databind.node.ObjectNode;

View File

@@ -1,7 +1,7 @@
package com.cameleer.license.minter.cli; package com.cameleer.license.minter.cli;
import com.cameleer.license.minter.LicenseMinter; import com.cameleer.license.minter.LicenseMinter;
import com.cameleer.server.core.license.LicenseInfo; import com.cameleer.license.LicenseInfo;
import java.io.PrintStream; import java.io.PrintStream;
import java.nio.file.Files; import java.nio.file.Files;
@@ -107,7 +107,7 @@ public final class LicenseMinterCli {
} }
try { try {
String pubB64 = Files.readString(Path.of(pubPath)).trim(); String pubB64 = Files.readString(Path.of(pubPath)).trim();
new com.cameleer.server.core.license.LicenseValidator(pubB64, tenant).validate(token); new com.cameleer.license.LicenseValidator(pubB64, tenant).validate(token);
out.println("verified ok"); out.println("verified ok");
} catch (Exception ve) { } catch (Exception ve) {
err.println("VERIFY FAILED: " + ve.getMessage()); err.println("VERIFY FAILED: " + ve.getMessage());

View File

@@ -1,7 +1,7 @@
package com.cameleer.license.minter; package com.cameleer.license.minter;
import com.cameleer.server.core.license.LicenseInfo; import com.cameleer.license.LicenseInfo;
import com.cameleer.server.core.license.LicenseValidator; import com.cameleer.license.LicenseValidator;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import java.security.KeyPair; import java.security.KeyPair;

View File

@@ -1,6 +1,6 @@
package com.cameleer.license.minter.cli; package com.cameleer.license.minter.cli;
import com.cameleer.server.core.license.LicenseValidator; import com.cameleer.license.LicenseValidator;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.api.io.TempDir;

View File

@@ -4,8 +4,8 @@ import com.cameleer.server.app.license.LicenseRepository;
import com.cameleer.server.app.license.LicenseService; import com.cameleer.server.app.license.LicenseService;
import com.cameleer.server.core.admin.AuditService; import com.cameleer.server.core.admin.AuditService;
import com.cameleer.server.core.license.LicenseGate; import com.cameleer.server.core.license.LicenseGate;
import com.cameleer.server.core.license.LicenseInfo; import com.cameleer.license.LicenseInfo;
import com.cameleer.server.core.license.LicenseValidator; import com.cameleer.license.LicenseValidator;
import jakarta.annotation.PostConstruct; import jakarta.annotation.PostConstruct;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;

View File

@@ -3,7 +3,7 @@ package com.cameleer.server.app.controller;
import com.cameleer.server.app.license.LicenseRepository; import com.cameleer.server.app.license.LicenseRepository;
import com.cameleer.server.app.license.LicenseService; import com.cameleer.server.app.license.LicenseService;
import com.cameleer.server.core.license.LicenseGate; import com.cameleer.server.core.license.LicenseGate;
import com.cameleer.server.core.license.LicenseInfo; import com.cameleer.license.LicenseInfo;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;

View File

@@ -1,7 +1,6 @@
package com.cameleer.server.app.dto; package com.cameleer.server.app.dto;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
@Schema(description = "Authentication capabilities reported to the SPA so it can render the login page deterministically") @Schema(description = "Authentication capabilities reported to the SPA so it can render the login page deterministically")
public record AuthCapabilitiesResponse( public record AuthCapabilitiesResponse(
@@ -12,7 +11,7 @@ public record AuthCapabilitiesResponse(
@Schema(description = "OIDC interactive login") @Schema(description = "OIDC interactive login")
public record Oidc( public record Oidc(
@Schema(description = "Whether OIDC is configured AND enabled") boolean enabled, @Schema(description = "Whether OIDC is configured AND enabled") boolean enabled,
@Schema(description = "Best-effort display label, e.g. \"Logto\", \"Keycloak\", \"Single Sign-On\"") @NotNull String providerName, @Schema(description = "Best-effort display label, e.g. \"Logto\", \"Keycloak\", \"Single Sign-On\"") String providerName,
@Schema(description = "When true, OIDC is the canonical entry point and the SPA hides the local form unless ?local is set") boolean primary @Schema(description = "When true, OIDC is the canonical entry point and the SPA hides the local form unless ?local is set") boolean primary
) {} ) {}

View File

@@ -1,7 +1,7 @@
package com.cameleer.server.app.license; package com.cameleer.server.app.license;
import com.cameleer.server.core.license.LicenseInfo; import com.cameleer.license.LicenseInfo;
import com.cameleer.server.core.license.LicenseState; import com.cameleer.license.LicenseState;
import java.util.Objects; import java.util.Objects;

View File

@@ -3,8 +3,8 @@ package com.cameleer.server.app.license;
import com.cameleer.server.core.admin.AuditCategory; import com.cameleer.server.core.admin.AuditCategory;
import com.cameleer.server.core.admin.AuditResult; import com.cameleer.server.core.admin.AuditResult;
import com.cameleer.server.core.admin.AuditService; import com.cameleer.server.core.admin.AuditService;
import com.cameleer.license.LicenseLimits;
import com.cameleer.server.core.license.LicenseGate; import com.cameleer.server.core.license.LicenseGate;
import com.cameleer.server.core.license.LicenseLimits;
import io.micrometer.core.instrument.Counter; import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry; import io.micrometer.core.instrument.simple.SimpleMeterRegistry;

View File

@@ -1,7 +1,7 @@
package com.cameleer.server.app.license; package com.cameleer.server.app.license;
import com.cameleer.license.LicenseInfo;
import com.cameleer.server.core.license.LicenseGate; import com.cameleer.server.core.license.LicenseGate;
import com.cameleer.server.core.license.LicenseInfo;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ControllerAdvice;

View File

@@ -1,7 +1,7 @@
package com.cameleer.server.app.license; package com.cameleer.server.app.license;
import com.cameleer.server.core.license.LicenseInfo; import com.cameleer.license.LicenseInfo;
import com.cameleer.server.core.license.LicenseState; import com.cameleer.license.LicenseState;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;

View File

@@ -1,7 +1,7 @@
package com.cameleer.server.app.license; package com.cameleer.server.app.license;
import com.cameleer.server.core.license.LicenseGate; import com.cameleer.server.core.license.LicenseGate;
import com.cameleer.server.core.license.LicenseState; import com.cameleer.license.LicenseState;
import io.micrometer.core.instrument.Gauge; import io.micrometer.core.instrument.Gauge;
import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.core.instrument.MeterRegistry;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;

View File

@@ -3,9 +3,9 @@ package com.cameleer.server.app.license;
import com.cameleer.server.core.admin.AuditCategory; import com.cameleer.server.core.admin.AuditCategory;
import com.cameleer.server.core.admin.AuditResult; import com.cameleer.server.core.admin.AuditResult;
import com.cameleer.server.core.admin.AuditService; import com.cameleer.server.core.admin.AuditService;
import com.cameleer.license.LicenseInfo;
import com.cameleer.license.LicenseValidator;
import com.cameleer.server.core.license.LicenseGate; import com.cameleer.server.core.license.LicenseGate;
import com.cameleer.server.core.license.LicenseInfo;
import com.cameleer.server.core.license.LicenseValidator;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.ApplicationEventPublisher;

View File

@@ -1,7 +1,7 @@
package com.cameleer.server.app.license; package com.cameleer.server.app.license;
import com.cameleer.server.core.license.LicenseGate; import com.cameleer.server.core.license.LicenseGate;
import com.cameleer.server.core.license.LicenseLimits; import com.cameleer.license.LicenseLimits;
import com.cameleer.server.core.runtime.Environment; import com.cameleer.server.core.runtime.Environment;
import com.cameleer.server.core.runtime.EnvironmentRepository; import com.cameleer.server.core.runtime.EnvironmentRepository;
import org.slf4j.Logger; import org.slf4j.Logger;

View File

@@ -0,0 +1,52 @@
package com.cameleer.server.app.security;
import com.cameleer.server.app.dto.AuthCapabilitiesResponse;
import com.cameleer.server.core.security.OidcConfig;
import com.cameleer.server.core.security.OidcConfigRepository;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Optional;
/**
* Reports auth capabilities so the SPA renders the login page deterministically
* instead of inferring from {@code GET /api/v1/auth/oidc/config} 200/404.
*
* <p>Unauthenticated by design — the SPA calls this before any sign-in attempt.
* Inherits permit-all from the {@code /api/v1/auth/**} matcher in
* {@link SecurityConfig}.
*
* <p>Future deferred work (issue #154) extends this same payload with MFA
* enrollment URL and password-reset URL fields.
*/
@RestController
@RequestMapping("/api/v1/auth")
@Tag(name = "Authentication", description = "Login and token refresh endpoints")
public class AuthCapabilitiesController {
private final OidcConfigRepository oidcConfigRepository;
public AuthCapabilitiesController(OidcConfigRepository oidcConfigRepository) {
this.oidcConfigRepository = oidcConfigRepository;
}
@GetMapping("/capabilities")
@Operation(summary = "Auth capabilities for the SPA login page")
@ApiResponse(responseCode = "200", description = "Capabilities resolved")
public ResponseEntity<AuthCapabilitiesResponse> getCapabilities() {
Optional<OidcConfig> config = oidcConfigRepository.find();
boolean oidcEnabled = config.isPresent() && config.get().enabled();
String providerName = oidcEnabled
? OidcProviderNameDeriver.deriveName(config.get().issuerUri())
: "";
var oidc = new AuthCapabilitiesResponse.Oidc(oidcEnabled, providerName, oidcEnabled);
var local = new AuthCapabilitiesResponse.LocalAccounts(true, oidcEnabled);
return ResponseEntity.ok(new AuthCapabilitiesResponse(oidc, local));
}
}

View File

@@ -84,9 +84,12 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
JwtValidationResult result = jwtService.validateAccessToken(token); JwtValidationResult result = jwtService.validateAccessToken(token);
String subject = result.subject(); String subject = result.subject();
// Token revocation check: reject tokens issued before revocation timestamp // Token revocation check: reject tokens issued before revocation timestamp.
// JWT subject carries the "user:" prefix; users.user_id is the bare form
// (see CLAUDE.md "User ID conventions"). Strip before lookup.
if (subject.startsWith("user:") && result.issuedAt() != null) { if (subject.startsWith("user:") && result.issuedAt() != null) {
userRepository.findById(subject).ifPresent(user -> { String userId = subject.substring(5);
userRepository.findById(userId).ifPresent(user -> {
Instant revoked = user.tokenRevokedBefore(); Instant revoked = user.tokenRevokedBefore();
if (revoked != null && result.issuedAt().isBefore(revoked)) { if (revoked != null && result.issuedAt().isBefore(revoked)) {
serverMetrics.recordAuthFailure("revoked"); serverMetrics.recordAuthFailure("revoked");

View File

@@ -183,6 +183,26 @@ public class UiAuthController {
return ResponseEntity.ok(detail); return ResponseEntity.ok(detail);
} }
@PostMapping("/logout")
@Operation(summary = "Log out the current user (revoke all outstanding tokens)")
@ApiResponse(responseCode = "204", description = "Logged out (or no-op if not authenticated)")
public ResponseEntity<Void> logout(Authentication authentication, HttpServletRequest httpRequest) {
if (authentication == null || authentication.getName() == null
|| !authentication.getName().startsWith("user:")) {
return ResponseEntity.noContent().build();
}
String userId = stripSubjectPrefix(authentication.getName());
// +1ms guards against same-millisecond races: JWT iat is quantised to
// milliseconds (Date.from(now) in JwtServiceImpl), and the filter check
// is strict isBefore. Without the bump, a token issued in the same
// millisecond as logout would survive revocation.
userRepository.revokeTokensBefore(userId, Instant.now().plusMillis(1));
auditService.log(userId, "logout", AuditCategory.AUTH, null, null,
AuditResult.SUCCESS, httpRequest);
log.info("UI user logged out: {}", userId);
return ResponseEntity.noContent().build();
}
/** /**
* Map a JWT subject ({@code "user:<name>"} or {@code "user:oidc:<sub>"}) to the DB key: * Map a JWT subject ({@code "user:<name>"} or {@code "user:oidc:<sub>"}) to the DB key:
* just the bare username. FKs on {@code alert_rules.created_by}, * just the bare username. FKs on {@code alert_rules.created_by},

View File

@@ -2,7 +2,7 @@ package com.cameleer.server.app;
import com.cameleer.server.core.agent.AgentRegistryService; import com.cameleer.server.core.agent.AgentRegistryService;
import com.cameleer.server.core.license.LicenseGate; import com.cameleer.server.core.license.LicenseGate;
import com.cameleer.server.core.license.LicenseInfo; import com.cameleer.license.LicenseInfo;
import com.cameleer.server.core.security.JwtService; import com.cameleer.server.core.security.JwtService;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;

View File

@@ -0,0 +1,96 @@
package com.cameleer.server.app.controller;
import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.dto.AuthCapabilitiesResponse;
import com.cameleer.server.core.security.OidcConfig;
import com.cameleer.server.core.security.OidcConfigRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.boot.test.web.client.TestRestTemplate;
import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;
/**
* Integration tests for {@link com.cameleer.server.app.security.AuthCapabilitiesController}.
* Mocks {@link OidcConfigRepository} so each test controls the OIDC state it observes.
*/
class AuthCapabilitiesControllerIT extends AbstractPostgresIT {
@Autowired private TestRestTemplate restTemplate;
@MockBean private OidcConfigRepository oidcConfigRepository;
@BeforeEach
void resetMock() {
when(oidcConfigRepository.find()).thenReturn(Optional.empty());
}
@Test
void noOidcConfig_returnsLocalOnlyCaps() {
var resp = restTemplate.getForEntity("/api/v1/auth/capabilities", AuthCapabilitiesResponse.class);
assertThat(resp.getStatusCode().value()).isEqualTo(200);
assertThat(resp.getBody()).isNotNull();
assertThat(resp.getBody().oidc().enabled()).isFalse();
assertThat(resp.getBody().oidc().providerName()).isEqualTo("");
assertThat(resp.getBody().oidc().primary()).isFalse();
assertThat(resp.getBody().localAccounts().enabled()).isTrue();
assertThat(resp.getBody().localAccounts().adminRecoveryOnly()).isFalse();
}
@Test
void oidcDisabledRow_behavesLikeAbsent() {
OidcConfig disabled = new OidcConfig(false, "https://auth.logto.example/", "client-id", "secret",
"roles", List.of("VIEWER"), true, "name", "sub", "", List.of());
when(oidcConfigRepository.find()).thenReturn(Optional.of(disabled));
var resp = restTemplate.getForEntity("/api/v1/auth/capabilities", AuthCapabilitiesResponse.class);
assertThat(resp.getStatusCode().value()).isEqualTo(200);
assertThat(resp.getBody().oidc().enabled()).isFalse();
assertThat(resp.getBody().oidc().providerName()).isEqualTo("");
assertThat(resp.getBody().oidc().primary()).isFalse();
assertThat(resp.getBody().localAccounts().adminRecoveryOnly()).isFalse();
}
@Test
void oidcEnabledLogto_returnsOidcPrimaryWithProviderName() {
OidcConfig enabled = new OidcConfig(true, "https://auth.logto.example/", "client-id", "secret",
"roles", List.of("VIEWER"), true, "name", "sub", "", List.of());
when(oidcConfigRepository.find()).thenReturn(Optional.of(enabled));
var resp = restTemplate.getForEntity("/api/v1/auth/capabilities", AuthCapabilitiesResponse.class);
assertThat(resp.getStatusCode().value()).isEqualTo(200);
assertThat(resp.getBody().oidc().enabled()).isTrue();
assertThat(resp.getBody().oidc().providerName()).isEqualTo("Logto");
assertThat(resp.getBody().oidc().primary()).isTrue();
assertThat(resp.getBody().localAccounts().enabled()).isTrue();
assertThat(resp.getBody().localAccounts().adminRecoveryOnly()).isTrue();
}
@Test
void oidcEnabledUnknownProvider_returnsGenericProviderName() {
OidcConfig enabled = new OidcConfig(true, "https://idp.example.com/", "client-id", "secret",
"roles", List.of("VIEWER"), true, "name", "sub", "", List.of());
when(oidcConfigRepository.find()).thenReturn(Optional.of(enabled));
var resp = restTemplate.getForEntity("/api/v1/auth/capabilities", AuthCapabilitiesResponse.class);
assertThat(resp.getStatusCode().value()).isEqualTo(200);
assertThat(resp.getBody().oidc().providerName()).isEqualTo("Single Sign-On");
assertThat(resp.getBody().oidc().primary()).isTrue();
assertThat(resp.getBody().localAccounts().adminRecoveryOnly()).isTrue();
}
@Test
void endpointIsUnauthenticated() {
var resp = restTemplate.getForEntity("/api/v1/auth/capabilities", String.class);
assertThat(resp.getStatusCode().value()).isEqualTo(200);
}
}

View File

@@ -22,7 +22,7 @@ import static org.assertj.core.api.Assertions.assertThat;
* Verifies that the {@code max_alert_rules} cap from the default tier is enforced at * Verifies that the {@code max_alert_rules} cap from the default tier is enforced at
* {@code POST /api/v1/environments/{envSlug}/alerts/rules}. Default tier * {@code POST /api/v1/environments/{envSlug}/alerts/rules}. Default tier
* {@code max_alert_rules = 2}; with no license installed the gate is in * {@code max_alert_rules = 2}; with no license installed the gate is in
* {@link com.cameleer.server.core.license.LicenseState#ABSENT} and the defaults are * {@link com.cameleer.license.LicenseState#ABSENT} and the defaults are
* authoritative. The first two creates succeed; the third must be rejected with the * authoritative. The first two creates succeed; the third must be rejected with the
* structured 403 envelope produced by {@link LicenseExceptionAdvice}. * structured 403 envelope produced by {@link LicenseExceptionAdvice}.
*/ */

View File

@@ -19,7 +19,7 @@ import static org.assertj.core.api.Assertions.assertThat;
/** /**
* Verifies that the {@code max_apps} cap from the default tier is enforced at * Verifies that the {@code max_apps} cap from the default tier is enforced at
* {@code POST /api/v1/environments/{envSlug}/apps}. Default tier {@code max_apps = 3}; with no * {@code POST /api/v1/environments/{envSlug}/apps}. Default tier {@code max_apps = 3}; with no
* license installed the gate is in {@link com.cameleer.server.core.license.LicenseState#ABSENT} * license installed the gate is in {@link com.cameleer.license.LicenseState#ABSENT}
* and the defaults are authoritative. The fourth create attempt must be rejected with the * and the defaults are authoritative. The fourth create attempt must be rejected with the
* structured 403 envelope produced by {@link LicenseExceptionAdvice}. * structured 403 envelope produced by {@link LicenseExceptionAdvice}.
*/ */

View File

@@ -1,7 +1,7 @@
package com.cameleer.server.app.license; package com.cameleer.server.app.license;
import com.cameleer.server.core.license.LicenseGate; import com.cameleer.server.core.license.LicenseGate;
import com.cameleer.server.core.license.LicenseInfo; import com.cameleer.license.LicenseInfo;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import java.time.Instant; import java.time.Instant;

View File

@@ -4,8 +4,8 @@ import com.cameleer.license.minter.LicenseMinter;
import com.cameleer.server.app.AbstractPostgresIT; import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper; import com.cameleer.server.app.TestSecurityHelper;
import com.cameleer.server.core.license.LicenseGate; import com.cameleer.server.core.license.LicenseGate;
import com.cameleer.server.core.license.LicenseInfo; import com.cameleer.license.LicenseInfo;
import com.cameleer.server.core.license.LicenseState; import com.cameleer.license.LicenseState;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.AfterEach;

View File

@@ -1,7 +1,7 @@
package com.cameleer.server.app.license; package com.cameleer.server.app.license;
import com.cameleer.server.core.license.LicenseInfo; import com.cameleer.license.LicenseInfo;
import com.cameleer.server.core.license.LicenseState; import com.cameleer.license.LicenseState;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import java.time.Instant; import java.time.Instant;

View File

@@ -1,7 +1,7 @@
package com.cameleer.server.app.license; package com.cameleer.server.app.license;
import com.cameleer.server.core.license.LicenseGate; import com.cameleer.server.core.license.LicenseGate;
import com.cameleer.server.core.license.LicenseInfo; import com.cameleer.license.LicenseInfo;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry; import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;

View File

@@ -4,9 +4,9 @@ import com.cameleer.server.core.admin.AuditCategory;
import com.cameleer.server.core.admin.AuditResult; import com.cameleer.server.core.admin.AuditResult;
import com.cameleer.server.core.admin.AuditService; import com.cameleer.server.core.admin.AuditService;
import com.cameleer.server.core.license.LicenseGate; import com.cameleer.server.core.license.LicenseGate;
import com.cameleer.server.core.license.LicenseInfo; import com.cameleer.license.LicenseInfo;
import com.cameleer.server.core.license.LicenseState; import com.cameleer.license.LicenseState;
import com.cameleer.server.core.license.LicenseValidator; import com.cameleer.license.LicenseValidator;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.ApplicationEventPublisher;

View File

@@ -20,7 +20,7 @@ import static org.assertj.core.api.Assertions.assertThat;
* Verifies that the {@code max_outbound_connections} cap from the default tier is enforced at * Verifies that the {@code max_outbound_connections} cap from the default tier is enforced at
* {@code POST /api/v1/admin/outbound-connections}. Default tier * {@code POST /api/v1/admin/outbound-connections}. Default tier
* {@code max_outbound_connections = 1}; with no license installed the gate is in * {@code max_outbound_connections = 1}; with no license installed the gate is in
* {@link com.cameleer.server.core.license.LicenseState#ABSENT} and the defaults are * {@link com.cameleer.license.LicenseState#ABSENT} and the defaults are
* authoritative. The first create succeeds; the second must be rejected with the structured * authoritative. The first create succeeds; the second must be rejected with the structured
* 403 envelope produced by {@link LicenseExceptionAdvice}. * 403 envelope produced by {@link LicenseExceptionAdvice}.
*/ */

View File

@@ -1,8 +1,8 @@
package com.cameleer.server.app.license; package com.cameleer.server.app.license;
import com.cameleer.server.core.license.LicenseGate; import com.cameleer.server.core.license.LicenseGate;
import com.cameleer.server.core.license.LicenseInfo; import com.cameleer.license.LicenseInfo;
import com.cameleer.server.core.license.LicenseState; import com.cameleer.license.LicenseState;
import com.cameleer.server.core.runtime.Environment; import com.cameleer.server.core.runtime.Environment;
import com.cameleer.server.core.runtime.EnvironmentRepository; import com.cameleer.server.core.runtime.EnvironmentRepository;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;

View File

@@ -3,7 +3,7 @@ package com.cameleer.server.app.license;
import com.cameleer.server.app.AbstractPostgresIT; import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper; import com.cameleer.server.app.TestSecurityHelper;
import com.cameleer.server.core.license.LicenseGate; import com.cameleer.server.core.license.LicenseGate;
import com.cameleer.server.core.license.LicenseState; import com.cameleer.license.LicenseState;
import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;

View File

@@ -0,0 +1,81 @@
package com.cameleer.server.app.security;
import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.core.security.JwtService;
import com.cameleer.server.core.security.UserInfo;
import com.cameleer.server.core.security.UserRepository;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import java.time.Instant;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Integration test verifying that {@code users.token_revoked_before} is honoured
* by {@link JwtAuthenticationFilter}. Regression for the prefix-mismatch bug
* where the filter looked up the JWT subject ({@code user:alice}) against
* {@code users.user_id} (bare {@code alice}), so revocation never fired.
*/
class JwtRevocationIT extends AbstractPostgresIT {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private JwtService jwtService;
@Autowired
private UserRepository userRepository;
@AfterEach
void cleanup() {
userRepository.delete("revoke-me");
userRepository.delete("never-revoked");
}
@Test
void revokedTokenIsRejectedOnAuthenticatedRequest() {
userRepository.upsert(new UserInfo(
"revoke-me", "local", "", "Revoke Me", Instant.now()));
String accessToken = jwtService.createAccessToken(
"user:revoke-me", "user", List.of("VIEWER"));
ResponseEntity<String> before = call(accessToken);
assertThat(before.getStatusCode()).isEqualTo(HttpStatus.OK);
userRepository.revokeTokensBefore("revoke-me", Instant.now().plusSeconds(1));
ResponseEntity<String> after = call(accessToken);
assertThat(after.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
}
@Test
void unrevokedUserTokenIsAccepted() {
userRepository.upsert(new UserInfo(
"never-revoked", "local", "", "Never Revoked", Instant.now()));
String accessToken = jwtService.createAccessToken(
"user:never-revoked", "user", List.of("VIEWER"));
ResponseEntity<String> resp = call(accessToken);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
}
private ResponseEntity<String> call(String accessToken) {
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(accessToken);
return restTemplate.exchange(
"/api/v1/auth/me",
HttpMethod.GET,
new HttpEntity<>(headers),
String.class);
}
}

View File

@@ -0,0 +1,69 @@
package com.cameleer.server.app.security;
import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.core.security.JwtService;
import com.cameleer.server.core.security.UserInfo;
import com.cameleer.server.core.security.UserRepository;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.jdbc.core.JdbcTemplate;
import java.time.Instant;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class LogoutControllerIT extends AbstractPostgresIT {
@Autowired TestRestTemplate restTemplate;
@Autowired JwtService jwtService;
@Autowired UserRepository userRepository;
@Autowired JdbcTemplate jdbc;
@AfterEach
void cleanup() {
userRepository.delete("logout-test");
jdbc.update("DELETE FROM audit_log WHERE username = ?", "logout-test");
}
@Test
void logoutRevokesTokensAuditsAndRejectsSubsequentCalls() {
userRepository.upsert(new UserInfo("logout-test", "local", "", "Logout Test", Instant.now()));
String accessToken = jwtService.createAccessToken("user:logout-test", "user", List.of("VIEWER"));
HttpHeaders authed = new HttpHeaders();
authed.setBearerAuth(accessToken);
ResponseEntity<Void> logoutResp = restTemplate.exchange(
"/api/v1/auth/logout", HttpMethod.POST, new HttpEntity<>(authed), Void.class);
assertThat(logoutResp.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
Instant revokedAt = jdbc.queryForObject(
"SELECT token_revoked_before FROM users WHERE user_id = ?",
(rs, n) -> rs.getTimestamp(1).toInstant(), "logout-test");
assertThat(revokedAt).isAfter(Instant.now().minusSeconds(10));
Long auditCount = jdbc.queryForObject(
"SELECT COUNT(*) FROM audit_log WHERE category = 'AUTH' AND action = 'logout' AND username = ?",
Long.class, "logout-test");
assertThat(auditCount).isEqualTo(1L);
ResponseEntity<String> meResp = restTemplate.exchange(
"/api/v1/auth/me", HttpMethod.GET, new HttpEntity<>(authed), String.class);
assertThat(meResp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
}
@Test
void logoutWithoutTokenReturns204NoOp() {
ResponseEntity<Void> resp = restTemplate.exchange(
"/api/v1/auth/logout", HttpMethod.POST, HttpEntity.EMPTY, Void.class);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
}
}

View File

@@ -15,6 +15,10 @@
<description>Domain logic, storage, and agent registry</description> <description>Domain logic, storage, and agent registry</description>
<dependencies> <dependencies>
<dependency>
<groupId>com.cameleer</groupId>
<artifactId>cameleer-license-api</artifactId>
</dependency>
<dependency> <dependency>
<groupId>com.cameleer</groupId> <groupId>com.cameleer</groupId>
<artifactId>cameleer-common</artifactId> <artifactId>cameleer-common</artifactId>

View File

@@ -1,5 +1,9 @@
package com.cameleer.server.core.license; package com.cameleer.server.core.license;
import com.cameleer.license.LicenseInfo;
import com.cameleer.license.LicenseLimits;
import com.cameleer.license.LicenseState;
import com.cameleer.license.LicenseStateMachine;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;

View File

@@ -1,5 +1,8 @@
package com.cameleer.server.core.license; package com.cameleer.server.core.license;
import com.cameleer.license.DefaultTierLimits;
import com.cameleer.license.LicenseInfo;
import com.cameleer.license.LicenseState;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import java.time.Instant; import java.time.Instant;

View File

@@ -0,0 +1,118 @@
# Logout Hardening — SaaS Handoff (2026-04-27)
Action required by the cameleer-saas / Logto admin team before the cameleer-server logout fix is fully effective in customer environments.
## What changed in cameleer-server
The SPA now performs a proper OIDC RP-Initiated Logout: a top-level navigation to the IdP's `end_session_endpoint` with `id_token_hint`, `post_logout_redirect_uri`, and `client_id`. After Logto clears its session cookie it 302-redirects back to `post_logout_redirect_uri`.
Previously the SPA fired a cross-origin `fetch(... {mode:'no-cors'})` which is a no-op for OIDC — Logto's session cookie only clears under a top-level browsing context. Result: the next SSO click silently re-authenticated the prior user.
In addition, cameleer-server now exposes `POST /api/v1/auth/logout` which bumps `users.token_revoked_before = now().plusMillis(1)` for the calling user, invalidating every outstanding refresh + access token server-side. This protects against leaked-token scenarios that don't involve the IdP at all (XSS, copied bearer token, etc.). The `+1ms` guards against a same-millisecond race where a token issued in the exact ms of logout would otherwise survive the strict `isBefore` revocation check.
The SPA logout flow is now:
1. Best-effort `POST /api/v1/auth/logout` (server-side revocation).
2. Clear `localStorage` + Zustand auth state.
3. Set `sessionStorage['cameleer:signed_out'] = '1'` so the post-logout `/login` render shows a "You have been signed out successfully" splash instead of any auto-flow.
4. `window.location.replace(end_session_endpoint?id_token_hint=…&post_logout_redirect_uri=…&client_id=…)` for OIDC users (top-level navigation), or `/login` for local users.
`prompt=login` is also added to the OIDC authorization redirect on the way back in, as defence-in-depth: even if the IdP session cookie somehow survives logout, the IdP will re-prompt for credentials rather than silently re-authenticating.
## What the SaaS team must do
For **each cameleer-server tenant** registered as a Logto application, add the post-logout redirect URL to the application's allowed list:
```
Logto admin console
→ Applications → <cameleer-server tenant client>
→ Redirect URIs / Post sign-out redirect URIs
→ add: https://<tenant-base-url>/login
```
Example values (replace `<tenant-base-url>` with the customer's actual deployment URL):
| Tenant | Post sign-out redirect URI |
|---|---|
| acme-prod | `https://cameleer.acme.example.com/login` |
| acme-staging | `https://cameleer.staging.acme.example.com/login` |
| local-dev | `http://localhost:8081/login` |
If the SPA is served under a non-root base path (`config.basePath` in `ui/src/config.ts`), include the base path in the URL — e.g. `https://host/cameleer/login`. Logto matches strictly; trailing-slash and scheme mismatches fail the redirect.
## How to verify
After adding the URI:
1. Sign in to cameleer-server via SSO.
2. Sign out from the user menu.
3. Confirm the browser navigates through Logto's `end_session_endpoint` and lands on `/login` showing **"You have been signed out successfully."**
4. Click "Sign in again" → "Sign in with Single Sign-On" — Logto **must** show its login screen, **not** silently re-authenticate.
5. Sign in as a different user; confirm the dashboard reflects the new identity.
If silent re-auth still happens after step 4, the most likely cause is that `prompt=login` is being stripped by an intermediary or the IdP doesn't honor it for the configured client. The SPA already sets `prompt=login` defensively; verify by inspecting the redirect URL in DevTools → Network.
## Failure modes
| Symptom | Likely cause | Fix |
|---|---|---|
| Browser lands on Logto error "invalid post_logout_redirect_uri" | URI not registered, or trailing-slash / scheme mismatch | Add exact URL in Logto admin (Logto matches strictly) |
| User signs out, re-clicks SSO, lands back authenticated as same user | IdP session cookie not cleared — usually the end_session redirect failed to a Logto error page instead of the SPA's `/login` | Check Logto application → Audit logs for the failed `end_session` call; usually traces back to redirect URI registration |
| 204 from `/api/v1/auth/logout` but the SPA still appears authenticated locally | SPA bug — file an issue (server side is verified by `LogoutControllerIT` and `JwtRevocationIT`) | n/a |
| SPA splash never appears after logout | `sessionStorage['cameleer:signed_out']` not set, or `LoginPage` renders before `useState` initializer reads it — check `auth-store.logout` is being called before the navigation | Inspect `ui/src/auth/auth-store.ts:logout` |
| Stolen token still works after victim logged out | `JwtAuthenticationFilter` revocation lookup is broken (the original bug, fixed in `7066795c`) | Confirm filter at `JwtAuthenticationFilter:91` strips `user:` before `findById`. `JwtRevocationIT` is the regression. |
## Pointers
- Plan: `docs/superpowers/plans/2026-04-27-logout-hardening.md`
- Server endpoint: `cameleer-server-app/src/main/java/com/cameleer/server/app/security/UiAuthController.java` `POST /logout`
- Filter revocation check: `cameleer-server-app/src/main/java/com/cameleer/server/app/security/JwtAuthenticationFilter.java:88-99`
- SPA logout: `ui/src/auth/auth-store.ts` `logout`
- SPA splash + `prompt=login`: `ui/src/auth/LoginPage.tsx`
- Server ITs: `JwtRevocationIT`, `LogoutControllerIT` (both in `cameleer-server-app/src/test/java/com/cameleer/server/app/security/`)
- SaaS reference implementation: `cameleer-saas/ui/src/auth/useAuth.ts` (`@logto/react` `signOut(redirectUri)` + `cameleer:signed_out` sessionStorage flag pattern, mirrored here)
## Verification
### Automated (run on `feature/logout-hardening` HEAD `7837272a`, 2026-04-27)
| Check | Outcome |
|---|---|
| `JwtRevocationIT` (2 tests — revoked-token rejected, unrevoked-token accepted) | ✅ PASS |
| `LogoutControllerIT` (2 tests — authenticated logout revokes+audits+rejects subsequent calls; unauthenticated logout 204 no-op) | ✅ PASS |
| Reactor build | ✅ BUILD SUCCESS |
| `ui/ npm run typecheck` | ✅ 0 errors |
| `ui/ npm run build` | ✅ built in 1.21s (pre-existing chunk-size warning unchanged, unrelated) |
The pre-existing revocation-bug regression (token still works after logout) is now covered by `JwtRevocationIT.revokedTokenIsRejectedOnAuthenticatedRequest` and the end-to-end logout flow by `LogoutControllerIT.logoutRevokesTokensAuditsAndRejectsSubsequentCalls`. Both depend on the `JwtAuthenticationFilter` prefix-strip fix in commit `7066795c`.
### End-to-end against running jar (curl, 2026-04-27, post-merge `664acf26`)
Driven against the real `cameleer-server-app-1.0-SNAPSHOT.jar` running on `:8081` with temp Postgres + ClickHouse and env-var admin (`CAMELEER_SERVER_SECURITY_UIUSER=admin`):
| Step | Call | Expected | Actual |
|---|---|---|---|
| 1 | `GET /auth/me` with fresh token | 200 | ✅ 200 |
| 2 | `POST /auth/logout` (authenticated) | 204 | ✅ 204 |
| 3 | `GET /auth/me` with same (now revoked) token | 401 | ✅ 401 |
| 4 | `POST /auth/logout` without any token | 204 (best-effort no-op) | ✅ 204 |
| 5 | `users.token_revoked_before` for `admin` | non-null timestamp | ✅ `2026-04-27 10:15:47.259973+00` |
| 6 | `audit_log` row | `username=admin, action=logout, category=AUTH, result=SUCCESS` | ✅ |
This proves the full server-side chain is wired correctly: the controller revokes, the audit row lands, the `JwtAuthenticationFilter` prefix-strip fix from `7066795c` correctly enforces the revocation against the bare `users.user_id`, and the unauthenticated path is the no-op the SPA's logout depends on.
### SPA flow (verified by code inspection — Playwright MCP allowlist blocked browser drive)
The Playwright MCP server in this environment has a fixed `--allowed-origins` list that doesn't include `http://localhost:8081`, so a browser-driven smoke wasn't possible without restarting Claude Code. Instead, the SPA logout path was reviewed line-by-line during Tasks 4 + 5:
- `ui/src/auth/auth-store.ts:logout` — best-effort `api.POST('/auth/logout')` → clears all five auth-related localStorage keys (access, refresh, username, oidc-end-session, oidc-id-token, oidc-client-id) → sets `sessionStorage['cameleer:signed_out']='1'``window.location.replace(end_session_endpoint?id_token_hint=…&post_logout_redirect_uri=…&client_id=…)` for OIDC users, or `/login` for local users.
- `ui/src/auth/LoginPage.tsx` — reads + clears the `signed_out` flag in a one-shot `useState` initializer; renders the "You have been signed out successfully." splash card; adds `prompt=login` to the OIDC authorization URL.
- Type-check + production build green.
A future run with Playwright access (or the user's own browser) should re-verify the visual flow before declaring this 100% closed.
### Manual — still owed
- [ ] Register `https://<tenant-base-url>/login` as a `post_logout_redirect_uri` on the Logto application for each cameleer-server tenant (per the table above). **Blocking** for OIDC users; without this, end_session redirects to a Logto error page.
- [ ] Browser smoke for local-user logout (visual confirmation of the splash and "Sign in again" form re-render). Server-side behavior is already proven by the curl run above.
- [ ] OIDC-user smoke against Logto: sign in as user A → sign out → confirm top-level navigation through Logto's `end_session_endpoint` → splash renders → "Sign in again" → "Sign in with SSO" → confirm Logto **shows its login screen** (not silent re-auth) → sign in as user B → confirm dashboard reflects B (not A). This is the original repro scenario.

View File

@@ -0,0 +1,801 @@
# Logout Hardening Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make logout fully invalidate the user's session — server-side JWT revocation, OIDC RP-initiated logout via top-level redirect, and a "signed out" landing experience that prevents accidental silent re-authentication.
**Architecture:** Three layers. (1) Server adds `POST /api/v1/auth/logout` that bumps `users.token_revoked_before = now()`, killing all outstanding refresh + access tokens via the existing `JwtAuthenticationFilter` revocation check. (2) SPA replaces the broken `fetch(end_session, {mode:'no-cors'})` with a proper top-level navigation to the OIDC `end_session_endpoint`, passing `id_token_hint` + `post_logout_redirect_uri` + `client_id`. (3) A `cameleer:signed_out` `sessionStorage` flag lets the post-logout `LoginPage` confirm the action and prevents auto-flow loops; `prompt=login` on the OIDC auth request adds defence-in-depth for IdPs that retain credential caches outside the session cookie.
**Tech Stack:** Spring Boot 3 + Spring Security (server), React + Zustand + TypeScript (SPA), JUnit 5 + Spring Boot Test + Testcontainers (IT), OIDC RP-Initiated Logout 1.0.
**Validates against:** cameleer-saas `ui/src/auth/useAuth.ts` + `LoginPage.tsx` (Logto SDK reference implementation).
**Pre-existing bug fixed in passing:** `JwtAuthenticationFilter.java:89` calls `userRepository.findById(subject)` with the prefixed JWT subject (`user:alice`), but `users.user_id` is bare (`alice`). Result: the token-revocation feature has been silently inert since it was added. The new logout endpoint depends on this working, so the fix is Task 1.
---
## File Structure
**Server (`cameleer-server-app/`):**
| File | Action | Responsibility |
|---|---|---|
| `src/main/java/com/cameleer/server/app/security/JwtAuthenticationFilter.java` | Modify | Strip `user:` prefix before `findById` so revocation actually fires |
| `src/main/java/com/cameleer/server/app/security/UiAuthController.java` | Modify | Add `POST /logout` |
| `src/test/java/com/cameleer/server/app/security/JwtRevocationIT.java` | Create | Regression: revoked tokens are rejected |
| `src/test/java/com/cameleer/server/app/security/LogoutControllerIT.java` | Create | End-to-end: login → logout → token rejected; audit row written |
**SPA (`ui/`):**
| File | Action | Responsibility |
|---|---|---|
| `src/auth/auth-store.ts` | Modify | New `logout()`: server call → clear local state → set signed_out flag → top-level redirect to `end_session_endpoint` |
| `src/auth/LoginPage.tsx` | Modify | Read `signed_out` flag → render "Signed out" card; add `prompt=login` to OIDC redirect |
| `src/api/schema.d.ts` | Regen | Picks up new `/auth/logout` endpoint |
| `src/api/openapi.json` | Regen | Source for schema regen |
**Rules / docs:**
| File | Action | Responsibility |
|---|---|---|
| `.claude/rules/app-classes.md` | Modify | Document `POST /auth/logout` on `UiAuthController` listing |
| `docs/handoff/2026-04-27-logout-hardening.md` | Create | SaaS-side operational note: register `post_logout_redirect_uri` per cameleer-server tenant in Logto |
---
## Task 1: Fix the pre-existing revocation lookup bug (TDD regression)
**Files:**
- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/security/JwtAuthenticationFilter.java:88-96`
- Create: `cameleer-server-app/src/test/java/com/cameleer/server/app/security/JwtRevocationIT.java`
- [ ] **Step 1: Write the failing IT**
Create `cameleer-server-app/src/test/java/com/cameleer/server/app/security/JwtRevocationIT.java`:
```java
package com.cameleer.server.app.security;
import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.core.security.JwtService;
import com.cameleer.server.core.security.UserInfo;
import com.cameleer.server.core.security.UserRepository;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.client.RestTemplate;
import java.time.Instant;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class JwtRevocationIT extends AbstractPostgresIT {
@LocalServerPort int port;
@Autowired JwtService jwtService;
@Autowired UserRepository userRepository;
@Test
void revokedTokenIsRejectedOnAuthenticatedRequest() {
// Arrange: a user exists, holds a valid access token
userRepository.upsert(new UserInfo("revoke-me", "local", "", "Revoke Me", Instant.now()));
String accessToken = jwtService.createAccessToken("user:revoke-me", "user", List.of("VIEWER"));
// Sanity: token works before revocation
ResponseEntity<String> before = call(accessToken);
assertThat(before.getStatusCode()).isEqualTo(HttpStatus.OK);
// Act: revoke all tokens for this user
userRepository.revokeTokensBefore("revoke-me", Instant.now().plusSeconds(1));
// Assert: same token is now rejected
ResponseEntity<String> after = call(accessToken);
assertThat(after.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
}
private ResponseEntity<String> call(String accessToken) {
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(accessToken);
return new RestTemplate().exchange(
"http://localhost:" + port + "/api/v1/auth/me",
HttpMethod.GET, new HttpEntity<>(headers), String.class);
}
}
```
- [ ] **Step 2: Run test to verify it fails (proving the bug)**
Run: `mvn -pl cameleer-server-app -Dtest=JwtRevocationIT verify`
Expected: FAIL — the second `call()` returns 200 OK (revocation never fires because `findById("user:revoke-me")` returns empty).
- [ ] **Step 3: Fix the lookup**
Modify `cameleer-server-app/src/main/java/com/cameleer/server/app/security/JwtAuthenticationFilter.java:88-96`. Replace the block:
```java
// Token revocation check: reject tokens issued before revocation timestamp
if (subject.startsWith("user:") && result.issuedAt() != null) {
userRepository.findById(subject).ifPresent(user -> {
Instant revoked = user.tokenRevokedBefore();
if (revoked != null && result.issuedAt().isBefore(revoked)) {
serverMetrics.recordAuthFailure("revoked");
throw new com.cameleer.server.core.security.InvalidTokenException("Token revoked");
}
});
}
```
with:
```java
// Token revocation check: reject tokens issued before revocation timestamp.
// JWT subject carries the "user:" prefix; users.user_id is the bare form
// (see CLAUDE.md "User ID conventions"). Strip before lookup.
if (subject.startsWith("user:") && result.issuedAt() != null) {
String userId = subject.substring(5);
userRepository.findById(userId).ifPresent(user -> {
Instant revoked = user.tokenRevokedBefore();
if (revoked != null && result.issuedAt().isBefore(revoked)) {
serverMetrics.recordAuthFailure("revoked");
throw new com.cameleer.server.core.security.InvalidTokenException("Token revoked");
}
});
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `mvn -pl cameleer-server-app -Dtest=JwtRevocationIT verify`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add cameleer-server-app/src/main/java/com/cameleer/server/app/security/JwtAuthenticationFilter.java \
cameleer-server-app/src/test/java/com/cameleer/server/app/security/JwtRevocationIT.java
git commit -m "fix(auth): strip user: prefix before token-revocation lookup
JwtAuthenticationFilter compared the JWT subject (user:alice) against
users.user_id (bare alice), so token_revoked_before was never read for
any user. Strips the prefix to match the convention documented in
CLAUDE.md. Adds JwtRevocationIT as a regression."
```
---
## Task 2: Add `POST /api/v1/auth/logout`
**Files:**
- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/security/UiAuthController.java`
- Create: `cameleer-server-app/src/test/java/com/cameleer/server/app/security/LogoutControllerIT.java`
`/api/v1/auth/**` is `permitAll()` in `SecurityConfig.java:92`. We keep that and let the controller read `Authentication` opportunistically — if no token (already expired or missing), return 204 no-op so the SPA's best-effort call never fails.
- [ ] **Step 1: Write the failing IT**
Create `cameleer-server-app/src/test/java/com/cameleer/server/app/security/LogoutControllerIT.java`:
```java
package com.cameleer.server.app.security;
import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.core.security.JwtService;
import com.cameleer.server.core.security.UserInfo;
import com.cameleer.server.core.security.UserRepository;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.http.*;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.web.client.RestTemplate;
import java.time.Instant;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class LogoutControllerIT extends AbstractPostgresIT {
@LocalServerPort int port;
@Autowired JwtService jwtService;
@Autowired UserRepository userRepository;
@Autowired JdbcTemplate jdbc;
@Test
void logoutRevokesTokensAuditsAndRejectsSubsequentCalls() {
userRepository.upsert(new UserInfo("logout-test", "local", "", "Logout Test", Instant.now()));
String accessToken = jwtService.createAccessToken("user:logout-test", "user", List.of("VIEWER"));
// POST /auth/logout
HttpHeaders authed = new HttpHeaders();
authed.setBearerAuth(accessToken);
ResponseEntity<Void> logoutResp = new RestTemplate().exchange(
"http://localhost:" + port + "/api/v1/auth/logout",
HttpMethod.POST, new HttpEntity<>(authed), Void.class);
assertThat(logoutResp.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
// token_revoked_before is set
Instant revokedAt = jdbc.queryForObject(
"SELECT token_revoked_before FROM users WHERE user_id = ?",
(rs, n) -> rs.getTimestamp(1).toInstant(), "logout-test");
assertThat(revokedAt).isAfter(Instant.now().minusSeconds(10));
// Audit row written
Long auditCount = jdbc.queryForObject(
"SELECT COUNT(*) FROM audit_log WHERE category = 'AUTH' AND action = 'logout' AND username = ?",
Long.class, "logout-test");
assertThat(auditCount).isEqualTo(1L);
// Same token now rejected
ResponseEntity<String> meResp = new RestTemplate().exchange(
"http://localhost:" + port + "/api/v1/auth/me",
HttpMethod.GET, new HttpEntity<>(authed), String.class);
assertThat(meResp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
}
@Test
void logoutWithoutTokenReturns204NoOp() {
ResponseEntity<Void> resp = new RestTemplate().exchange(
"http://localhost:" + port + "/api/v1/auth/logout",
HttpMethod.POST, HttpEntity.EMPTY, Void.class);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `mvn -pl cameleer-server-app -Dtest=LogoutControllerIT verify`
Expected: FAIL — endpoint does not exist (404).
- [ ] **Step 3: Add the endpoint**
Modify `cameleer-server-app/src/main/java/com/cameleer/server/app/security/UiAuthController.java`. Add this method right after the `me(...)` method (before `stripSubjectPrefix`):
```java
@PostMapping("/logout")
@Operation(summary = "Log out the current user (revoke all outstanding tokens)")
@ApiResponse(responseCode = "204", description = "Logged out (or no-op if not authenticated)")
public ResponseEntity<Void> logout(Authentication authentication, HttpServletRequest httpRequest) {
if (authentication == null || authentication.getName() == null
|| !authentication.getName().startsWith("user:")) {
// Best-effort: SPA calls this even when its token is already gone.
return ResponseEntity.noContent().build();
}
String userId = stripSubjectPrefix(authentication.getName());
userRepository.revokeTokensBefore(userId, Instant.now());
auditService.log(userId, "logout", AuditCategory.AUTH, null, null,
AuditResult.SUCCESS, httpRequest);
log.info("UI user logged out: {}", userId);
return ResponseEntity.noContent().build();
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `mvn -pl cameleer-server-app -Dtest=LogoutControllerIT verify`
Expected: PASS (both tests).
- [ ] **Step 5: Commit**
```bash
git add cameleer-server-app/src/main/java/com/cameleer/server/app/security/UiAuthController.java \
cameleer-server-app/src/test/java/com/cameleer/server/app/security/LogoutControllerIT.java
git commit -m "feat(auth): add POST /auth/logout that revokes all user tokens
Bumps users.token_revoked_before = now() for the calling user, audited
under AuditCategory.AUTH. Best-effort: returns 204 even when the request
is unauthenticated, so the SPA can call it on every logout regardless of
token state. Token-rejection is enforced by the existing
JwtAuthenticationFilter revocation check (fixed in the previous commit)."
```
---
## Task 3: Regenerate OpenAPI schema for SPA consumption
Per CLAUDE.md "Regenerating OpenAPI schema (SPA types)" — required for every controller-level change.
- [ ] **Step 1: Build and run the server**
```bash
mvn -pl cameleer-server-app -DskipTests package
java -jar cameleer-server-app/target/cameleer-server-app-*.jar &
```
Wait until `Started CameleerServerApplication` appears in logs (port 8081 by default).
- [ ] **Step 2: Regenerate the schema**
```bash
cd ui && npm run generate-api:live
```
Expected: `ui/src/api/openapi.json` and `ui/src/api/schema.d.ts` updated. Diff shows `/auth/logout` POST entry under `paths`.
- [ ] **Step 3: Stop the server, verify SPA still type-checks**
```bash
pkill -f cameleer-server-app
cd ui && npm run typecheck
```
Expected: 0 errors.
- [ ] **Step 4: Commit**
```bash
git add ui/src/api/openapi.json ui/src/api/schema.d.ts
git commit -m "chore(ui): regenerate OpenAPI schema for /auth/logout"
```
---
## Task 4: Refactor SPA `auth-store.ts` logout
**File:** Modify `ui/src/auth/auth-store.ts`
Replace the broken `fetch(end_session, {mode:'no-cors'})` with: (1) best-effort server `POST /auth/logout` to revoke tokens, (2) clear localStorage + Zustand state, (3) set `cameleer:signed_out` `sessionStorage` flag, (4) top-level redirect to `end_session_endpoint` for OIDC users, otherwise navigate to local `/login`.
- [ ] **Step 1: Replace the `logout` action**
Modify `ui/src/auth/auth-store.ts:143-169`. Replace the entire `logout: () => { ... }` block with:
```ts
logout: async () => {
const accessToken = get().accessToken;
const endSessionEndpoint = localStorage.getItem('cameleer-oidc-end-session');
const idToken = localStorage.getItem('cameleer-oidc-id-token');
const clientId = localStorage.getItem('cameleer-oidc-client-id');
// Best-effort server-side revocation. Don't await failures — the SPA
// logout must always proceed (e.g. token already expired).
if (accessToken) {
try {
await api.POST('/auth/logout', {});
} catch {
// ignore; client-side cleanup below is still authoritative for the SPA
}
}
clearTokens();
localStorage.removeItem('cameleer-oidc-end-session');
localStorage.removeItem('cameleer-oidc-id-token');
localStorage.removeItem('cameleer-oidc-client-id');
set({
accessToken: null,
refreshToken: null,
username: null,
roles: [],
isAuthenticated: false,
error: null,
});
// Mark the upcoming /login render so it shows a "Signed out" splash and
// does not silently re-enter any auto-flow. Mirrors cameleer-saas
// ui/src/auth/useAuth.ts pattern.
sessionStorage.setItem('cameleer:signed_out', '1');
const localLoginUrl = `${config.basePath}login`;
if (endSessionEndpoint && idToken) {
// OIDC RP-Initiated Logout 1.0: top-level navigation, NOT fetch.
// Logto (and every compliant IdP) only clears its session cookie under
// a top-level browser request; cross-origin fetch leaves it intact.
const params = new URLSearchParams({
id_token_hint: idToken,
post_logout_redirect_uri: `${window.location.origin}${config.basePath}login`,
});
if (clientId) params.set('client_id', clientId);
window.location.replace(`${endSessionEndpoint}?${params}`);
} else {
window.location.replace(localLoginUrl);
}
},
```
Update the `AuthState` interface (top of file) to reflect the now-async signature:
```ts
logout: () => Promise<void>;
```
- [ ] **Step 2: Persist `clientId` at OIDC initiation**
Modify `ui/src/auth/LoginPage.tsx:77-79`. Replace:
```ts
if (data.endSessionEndpoint) {
localStorage.setItem('cameleer-oidc-end-session', data.endSessionEndpoint);
}
```
with:
```ts
if (data.endSessionEndpoint) {
localStorage.setItem('cameleer-oidc-end-session', data.endSessionEndpoint);
}
if (data.clientId) {
localStorage.setItem('cameleer-oidc-client-id', data.clientId);
}
```
- [ ] **Step 3: Type-check**
```bash
cd ui && npm run typecheck
```
Expected: 0 errors. The `logout` callers (only `useAuth.ts` and `LayoutShell.tsx`) accept a `() => void` signature and ignore the return; an async function is fire-and-forget compatible.
- [ ] **Step 4: Commit**
```bash
git add ui/src/auth/auth-store.ts ui/src/auth/LoginPage.tsx
git commit -m "fix(ui): proper OIDC logout — server revoke + top-level redirect
Previous logout fired fetch(end_session, {mode:'no-cors'}), which is a
no-op for OIDC: cross-origin fetch never clears the IdP's session cookie.
Result: subsequent SSO clicks silently re-authenticated the prior user.
New flow:
1. Best-effort POST /auth/logout to bump token_revoked_before.
2. Clear localStorage + Zustand state.
3. Set sessionStorage 'cameleer:signed_out=1' so /login renders a
confirmation splash (mirrors cameleer-saas pattern).
4. window.location.replace(end_session_endpoint?id_token_hint=…
&post_logout_redirect_uri=…&client_id=…) — top-level navigation, the
only form that actually clears the IdP session cookie.
client_id is now persisted at OIDC initiation alongside
end_session_endpoint and id_token, so logout has all three params
without an extra round-trip."
```
---
## Task 5: SPA `LoginPage` — `prompt=login` + signed-out splash
**File:** Modify `ui/src/auth/LoginPage.tsx`
Two changes: (1) add `prompt=login` to the OIDC redirect (defence-in-depth), (2) read `cameleer:signed_out` flag and render a "Signed out" card with an explicit "Sign in again" button.
- [ ] **Step 1: Add `prompt=login` to the OIDC redirect**
Modify `ui/src/auth/LoginPage.tsx:82-90`. Replace:
```ts
const params = new URLSearchParams({
response_type: 'code',
client_id: data.clientId,
redirect_uri: redirectUri,
scope: scopes.join(' '),
});
if (data.resource) params.set('resource', data.resource);
// Note: NO prompt=none. Per RFC 9700 §4.4, that's silent re-auth only;
// for first-time login it returns login_required and traps users on a local form.
window.location.href = `${data.authorizationEndpoint}?${params}`;
```
with:
```ts
const params = new URLSearchParams({
response_type: 'code',
client_id: data.clientId,
redirect_uri: redirectUri,
scope: scopes.join(' '),
// Defence-in-depth: even if RP-Initiated Logout did not fully clear
// the IdP session (proxy/cookie edge cases), prompt=login forces the
// IdP to re-prompt for credentials instead of silent re-auth.
prompt: 'login',
});
if (data.resource) params.set('resource', data.resource);
window.location.href = `${data.authorizationEndpoint}?${params}`;
```
- [ ] **Step 2: Read the signed-out flag in `LoginPage`**
Modify `ui/src/auth/LoginPage.tsx:41-50`. Replace:
```ts
export function LoginPage() {
const { isAuthenticated, login, loading, error } = useAuthStore();
const [searchParams] = useSearchParams();
const forceLocal = searchParams.has('local');
const subtitle = useMemo(() => SUBTITLES[Math.floor(Math.random() * SUBTITLES.length)], []);
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [oidcLoading, setOidcLoading] = useState(false);
```
with:
```ts
export function LoginPage() {
const { isAuthenticated, login, loading, error } = useAuthStore();
const [searchParams] = useSearchParams();
const forceLocal = searchParams.has('local');
const subtitle = useMemo(() => SUBTITLES[Math.floor(Math.random() * SUBTITLES.length)], []);
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [oidcLoading, setOidcLoading] = useState(false);
// Mirrors cameleer-saas: when logout sets this flag, render a "Signed out"
// confirmation instead of the regular form. The flag is one-shot — read +
// cleared on mount.
const [signedOut] = useState(() => {
const flag = sessionStorage.getItem('cameleer:signed_out');
if (flag) sessionStorage.removeItem('cameleer:signed_out');
return !!flag;
});
```
- [ ] **Step 3: Render the signed-out card**
Inside `LoginPage`, after `if (capsLoading) return null;` and before the `oidcPrimary` line, insert:
```tsx
if (signedOut) {
return (
<div className={styles.page}>
<Card className={styles.card}>
<div className={styles.loginForm}>
<div className={styles.logo}>
<img src={brandLogo} alt="" className={styles.logoImg} />
cameleer
</div>
<p className={styles.subtitle}>You have been signed out successfully.</p>
<Button
variant="primary"
onClick={() => { window.location.replace(`${config.basePath}login`); }}
className={styles.submitButton}
>
Sign in again
</Button>
</div>
</Card>
</div>
);
}
```
The button reload bounces back to `/login``signedOut` is `false` on the second render (flag was cleared in the `useState` initializer), so the regular form (or SSO button) renders.
- [ ] **Step 4: Type-check + visual smoke**
```bash
cd ui && npm run typecheck
cd ui && npm run dev # in another shell — open http://localhost:5173/login
```
Manually: log in, click "Sign out" in the user menu, confirm:
- Browser navigates to Logto end_session URL (not fetch).
- Returns to `/login` with the "Signed out successfully" card.
- "Sign in again" → SSO button visible → clicking it triggers Logto's login screen (not silent re-auth).
- [ ] **Step 5: Commit**
```bash
git add ui/src/auth/LoginPage.tsx
git commit -m "feat(ui): signed-out splash + prompt=login on OIDC redirect
Two defensive layers complementing the RP-Initiated Logout in the
previous commit:
1. cameleer:signed_out sessionStorage flag (set in auth-store.logout,
read+cleared in LoginPage) renders a 'You have been signed out
successfully' card with an explicit 'Sign in again' button. Mirrors
the cameleer-saas pattern (ui/src/auth/LoginPage.tsx).
2. prompt=login on the OIDC authorization redirect forces the IdP to
re-prompt for credentials even if its session cookie somehow
survived RP-Initiated Logout (proxy, race, misconfigured
post_logout_redirect_uri). RFC 6749 §3.1.2.1 / OIDC Core 1.0 §3.1.2.1."
```
---
## Task 6: Update `.claude/rules/app-classes.md`
**File:** Modify `.claude/rules/app-classes.md`
Document the new endpoint so future sessions don't re-discover the URL surface from scratch.
- [ ] **Step 1: Update the `UiAuthController` listing**
Find the line:
```
- `UiAuthController` — `/api/v1/auth` (login, refresh, me). Local username/password against env-var admin or DB BCrypt hash. Lockout after 5 failed attempts.
```
(There are two near-identical lines — under "Auth (flat)" and under "security/ — Spring Security". Update both for consistency.)
Replace each with:
```
- `UiAuthController` — `/api/v1/auth` (login, refresh, me, logout). Local username/password against env-var admin or DB BCrypt hash. Lockout after 5 failed attempts. `POST /logout` is permitAll — controller resolves the user from the access token if present, bumps `users.token_revoked_before = now()` to invalidate all outstanding refresh + access tokens (enforced by `JwtAuthenticationFilter`), audits `AuditCategory.AUTH / logout`, returns 204. Best-effort: 204 also when called without a token so the SPA's logout never fails on already-expired sessions.
```
- [ ] **Step 2: Commit**
```bash
git add .claude/rules/app-classes.md
git commit -m "docs(rules): document POST /auth/logout on UiAuthController"
```
---
## Task 7: SaaS-side operational handoff
**File:** Create `docs/handoff/2026-04-27-logout-hardening.md`
Document the cross-team requirement: SaaS team must register `post_logout_redirect_uri` for each cameleer-server tenant in Logto, otherwise the OIDC end_session call rejects with `invalid_request` and the user lands on a Logto error page instead of `/login`.
- [ ] **Step 1: Write the handoff doc**
Create `docs/handoff/2026-04-27-logout-hardening.md`:
````markdown
# Logout Hardening — SaaS Handoff (2026-04-27)
Action required by the cameleer-saas / Logto admin team before the cameleer-server logout fix is fully effective in customer environments.
## What changed in cameleer-server
The SPA now performs a proper OIDC RP-Initiated Logout: a top-level navigation to the IdP's `end_session_endpoint` with `id_token_hint`, `post_logout_redirect_uri`, and `client_id`. After Logto clears its session cookie it 302-redirects back to `post_logout_redirect_uri`.
Previously the SPA fired a cross-origin `fetch(... {mode:'no-cors'})` which is a no-op for OIDC — Logto's session cookie only clears under a top-level browsing context. Result: the next SSO click silently re-authenticated the prior user.
## What the SaaS team must do
For **each cameleer-server tenant** registered as a Logto application, add the post-logout redirect URL to the application's allowed list:
```
Logto admin console
→ Applications → <cameleer-server tenant client>
→ Redirect URIs / Post sign-out redirect URIs
→ add: https://<tenant-base-url>/login
```
Example values (replace `<tenant-base-url>` with the customer's actual deployment URL):
| Tenant | Post sign-out redirect URI |
|---|---|
| acme-prod | `https://cameleer.acme.example.com/login` |
| acme-staging | `https://cameleer.staging.acme.example.com/login` |
| local-dev | `http://localhost:8081/login` |
If the SPA is served under a non-root base path (`config.basePath` in `ui/src/config.ts`), include the base path in the URL — e.g. `https://host/cameleer/login`.
## How to verify
After adding the URI:
1. Sign in to cameleer-server via SSO.
2. Sign out from the user menu.
3. Confirm the browser navigates through Logto and lands on `/login` showing "You have been signed out successfully."
4. Click "Sign in again" → "Sign in with Single Sign-On" — Logto must show its login screen, **not** silently re-authenticate. (If silent re-auth still happens, `prompt=login` and `post_logout_redirect_uri` registration are both required; the SPA already sets `prompt=login` defensively, so the most likely missing piece is the redirect URI registration.)
## Failure modes
| Symptom | Likely cause | Fix |
|---|---|---|
| Browser lands on Logto error "invalid post_logout_redirect_uri" | URI not registered or trailing-slash mismatch | Add exact URL in Logto admin (Logto matches strictly) |
| User signs out, re-clicks SSO, lands back authenticated as same user | Session cookie not cleared — happens if the logout request 302'd to an error page instead of completing | Check Logto application → Audit logs for the failed end_session call; usually the redirect URI |
| 204 from `/api/v1/auth/logout` but still authenticated locally | SPA bug — file an issue (server side is verified by `LogoutControllerIT`) | n/a |
## Pointers
- Plan: `docs/superpowers/plans/2026-04-27-logout-hardening.md`
- Server endpoint: `cameleer-server-app/.../security/UiAuthController.java` `POST /logout`
- SPA logout: `ui/src/auth/auth-store.ts` `logout`
- SaaS reference: `cameleer-saas/ui/src/auth/useAuth.ts` (`@logto/react` `signOut(redirectUri)`)
````
- [ ] **Step 2: Commit**
```bash
git add docs/handoff/2026-04-27-logout-hardening.md
git commit -m "docs(handoff): SaaS-side post_logout_redirect_uri requirement"
```
---
## Task 8: Full-stack manual smoke test
This is a verification step — no code changes. Execute against a running server with a real Logto instance reachable.
- [ ] **Step 1: Run the full IT suite**
```bash
mvn -pl cameleer-server-app verify
```
Expected: 0 failures. `JwtRevocationIT` and `LogoutControllerIT` both green.
- [ ] **Step 2: Run server + SPA against Logto**
In one shell:
```bash
java -jar cameleer-server-app/target/cameleer-server-app-*.jar
```
In another:
```bash
cd ui && npm run dev
```
- [ ] **Step 3: Local-user logout smoke**
1. Open http://localhost:5173/ → log in via the local form (env-var admin or seeded user).
2. Click "Sign out".
3. Open DevTools → Network: confirm `POST /api/v1/auth/logout` returned 204.
4. Confirm the SPA landed on `/login` with the "Signed out successfully" card.
5. Click "Sign in again" → confirm the local form is shown and works.
- [ ] **Step 4: OIDC-user logout smoke (Logto)**
Required Logto config: `post_logout_redirect_uri` for the cameleer-server client must include `http://localhost:5173/login` (per Task 7).
1. Reproduce the original bug first (optional sanity): `git stash`, log in via SSO as user A, log out, click SSO again — observe silent re-auth as A. `git stash pop`.
2. With the fix applied: log in via SSO as user A.
3. Click "Sign out".
4. Network tab: confirm `POST /api/v1/auth/logout` → 204, then a top-level navigation to `<logto>/oidc/session/end?...` → 302 back to `/login`.
5. Confirm the "Signed out" card renders.
6. Click "Sign in again" → "Sign in with SSO" → Logto **must** show its login screen (not silent re-auth).
7. Sign in as a *different* user B; confirm the dashboard reflects B's identity (not A's).
8. Sign out as B → "Sign in again" → sign in as A → reflects A.
- [ ] **Step 5: Token-revocation smoke**
Verify a stolen-token scenario can't outlive a logout.
1. Log in. In DevTools → Application → Local Storage, copy `cameleer-access-token`.
2. In a separate browser/curl, hit an authenticated endpoint with that token — must return 200:
```bash
curl -H "Authorization: Bearer <token>" http://localhost:5173/api/v1/auth/me
```
3. Sign out in the original tab.
4. Re-run the curl — must return 401.
- [ ] **Step 6: Document outcomes**
Append to `docs/handoff/2026-04-27-logout-hardening.md` under a new "Verification" section: which steps were exercised, against which IdP, and any deviations from expected behavior. If any deviation surfaces, file an issue and link from the handoff.
- [ ] **Step 7: Commit any handoff updates**
```bash
git add docs/handoff/2026-04-27-logout-hardening.md
git commit -m "docs(handoff): logout-hardening verification notes"
```
(Skip if no edits.)
---
## Self-review summary
- ✅ **Server-side revocation** — Task 1 (regression fix) + Task 2 (endpoint).
- ✅ **OIDC top-level redirect** — Task 4.
- ✅ **`prompt=login` defence** — Task 5.
-**Signed-out splash** — Task 5 (mirrors SaaS pattern).
-**Logto config note** — Task 7.
-**Rules updated** — Task 6.
-**Manual end-to-end verification** — Task 8 covers local user, OIDC user, stolen-token scenarios.
No tasks reference symbols not defined in earlier tasks. All code blocks are complete (no "TBD" or "similar to above"). Each task ends in a single atomic commit.

View File

@@ -20,6 +20,7 @@
<description>Observability server for Cameleer agents</description> <description>Observability server for Cameleer agents</description>
<modules> <modules>
<module>cameleer-license-api</module>
<module>cameleer-server-core</module> <module>cameleer-server-core</module>
<module>cameleer-server-app</module> <module>cameleer-server-app</module>
<module>cameleer-license-minter</module> <module>cameleer-license-minter</module>
@@ -40,6 +41,11 @@
<artifactId>cameleer-common</artifactId> <artifactId>cameleer-common</artifactId>
<version>${cameleer-common.version}</version> <version>${cameleer-common.version}</version>
</dependency> </dependency>
<dependency>
<groupId>com.cameleer</groupId>
<artifactId>cameleer-license-api</artifactId>
<version>${project.version}</version>
</dependency>
<dependency> <dependency>
<groupId>com.cameleer</groupId> <groupId>com.cameleer</groupId>
<artifactId>cameleer-server-core</artifactId> <artifactId>cameleer-server-core</artifactId>

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,46 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { ReactNode } from 'react';
vi.mock('../client', () => ({ api: { GET: vi.fn() } }));
import { api as apiClient } from '../client';
import { useAuthCapabilities } from './auth';
function wrapper({ children }: { children: ReactNode }) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
}
describe('useAuthCapabilities', () => {
beforeEach(() => vi.clearAllMocks());
it('returns the capabilities body on success', async () => {
(apiClient.GET as any).mockResolvedValue({
data: {
oidc: { enabled: true, providerName: 'Logto', primary: true },
localAccounts: { enabled: true, adminRecoveryOnly: true },
},
error: null,
});
const { result } = renderHook(() => useAuthCapabilities(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data?.oidc?.enabled).toBe(true);
expect(result.current.data?.oidc?.providerName).toBe('Logto');
expect(result.current.data?.localAccounts?.adminRecoveryOnly).toBe(true);
});
it('exposes error state when the request fails', async () => {
(apiClient.GET as any).mockResolvedValue({
data: undefined,
error: { message: 'boom' },
});
const { result } = renderHook(() => useAuthCapabilities(), { wrapper });
await waitFor(() => expect(result.current.isError).toBe(true));
});
});

View File

@@ -1,6 +1,8 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { config } from '../../config'; import { config } from '../../config';
import { useAuthStore } from '../../auth/auth-store'; import { useAuthStore } from '../../auth/auth-store';
import { api } from '../client';
import type { components } from '../schema';
export interface RoleSummary { export interface RoleSummary {
id: string; id: string;
@@ -46,3 +48,18 @@ export function useMe(enabled = false) {
staleTime: 30_000, staleTime: 30_000,
}); });
} }
export type AuthCapabilities = components['schemas']['AuthCapabilitiesResponse'];
export function useAuthCapabilities() {
return useQuery<AuthCapabilities>({
queryKey: ['auth', 'capabilities'],
queryFn: async () => {
const { data, error } = await api.GET('/auth/capabilities');
if (error || !data) throw new Error('Failed to load auth capabilities');
return data as AuthCapabilities;
},
staleTime: Infinity,
retry: false,
});
}

172
ui/src/api/schema.d.ts vendored
View File

@@ -753,6 +753,23 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/auth/logout": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Log out the current user (revoke all outstanding tokens) */
post: operations["logout"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/auth/login": { "/auth/login": {
parameters: { parameters: {
query?: never; query?: never;
@@ -1131,10 +1148,10 @@ export interface paths {
path?: never; path?: never;
cookie?: never; cookie?: never;
}; };
/** Get current license info */ /** Get current license state, invalid reason, and parsed envelope */
get: operations["getCurrent"]; get: operations["getCurrent"];
put?: never; put?: never;
/** Update license token at runtime */ /** Install or replace the license token at runtime */
post: operations["update_5"]; post: operations["update_5"];
delete?: never; delete?: never;
options?: never; options?: never;
@@ -1872,6 +1889,23 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/auth/capabilities": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Auth capabilities for the SPA login page */
get: operations["getCapabilities"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/agents/{id}/events": { "/agents/{id}/events": {
parameters: { parameters: {
query?: never; query?: never;
@@ -2005,6 +2039,22 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/admin/license/usage": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["get_4"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/database/tables": { "/admin/database/tables": {
parameters: { parameters: {
query?: never; query?: never;
@@ -2194,6 +2244,12 @@ export interface components {
color?: string; color?: string;
/** Format: date-time */ /** Format: date-time */
createdAt?: string; createdAt?: string;
/** Format: int32 */
executionRetentionDays?: number;
/** Format: int32 */
logRetentionDays?: number;
/** Format: int32 */
metricRetentionDays?: number;
}; };
/** @description Per-application dashboard settings */ /** @description Per-application dashboard settings */
AppSettingsRequest: { AppSettingsRequest: {
@@ -2704,8 +2760,8 @@ export interface components {
AttributeFilter: { AttributeFilter: {
key?: string; key?: string;
value?: string; value?: string;
keyOnly?: boolean;
wildcard?: boolean; wildcard?: boolean;
keyOnly?: boolean;
}; };
SearchRequest: { SearchRequest: {
status?: string; status?: string;
@@ -3594,6 +3650,29 @@ export interface components {
effectiveRoles?: components["schemas"]["RoleSummary"][]; effectiveRoles?: components["schemas"]["RoleSummary"][];
effectiveGroups?: components["schemas"]["GroupSummary"][]; effectiveGroups?: components["schemas"]["GroupSummary"][];
}; };
/** @description Authentication capabilities reported to the SPA so it can render the login page deterministically */
AuthCapabilitiesResponse: {
/** @description OIDC interactive login capability */
oidc?: components["schemas"]["Oidc"];
/** @description Local username/password account capability */
localAccounts?: components["schemas"]["LocalAccounts"];
};
/** @description Local username/password accounts */
LocalAccounts: {
/** @description Whether the local form is reachable at all */
enabled?: boolean;
/** @description When true, the SPA gates the local form behind ?local with an admin-recovery banner */
adminRecoveryOnly?: boolean;
};
/** @description OIDC interactive login */
Oidc: {
/** @description Whether OIDC is configured AND enabled */
enabled?: boolean;
/** @description Best-effort display label, e.g. "Logto", "Keycloak", "Single Sign-On" */
providerName?: string;
/** @description When true, OIDC is the canonical entry point and the SPA hides the local form unless ?local is set */
primary?: boolean;
};
SseEmitter: { SseEmitter: {
/** Format: int64 */ /** Format: int64 */
timeout?: number; timeout?: number;
@@ -3651,18 +3730,6 @@ export interface components {
/** Format: int32 */ /** Format: int32 */
roleCount?: number; roleCount?: number;
}; };
LicenseInfo: {
tier?: string;
features?: ("topology" | "lineage" | "correlation" | "debugger" | "replay")[];
limits?: {
[key: string]: number;
};
/** Format: date-time */
issuedAt?: string;
/** Format: date-time */
expiresAt?: string;
expired?: boolean;
};
GroupDetail: { GroupDetail: {
/** Format: uuid */ /** Format: uuid */
id?: string; id?: string;
@@ -3832,7 +3899,7 @@ export interface components {
username?: string; username?: string;
action?: string; action?: string;
/** @enum {string} */ /** @enum {string} */
category?: "INFRA" | "AUTH" | "USER_MGMT" | "CONFIG" | "RBAC" | "AGENT" | "OUTBOUND_CONNECTION_CHANGE" | "OUTBOUND_HTTP_TRUST_CHANGE" | "ALERT_RULE_CHANGE" | "ALERT_SILENCE_CHANGE" | "DEPLOYMENT"; category?: "INFRA" | "AUTH" | "USER_MGMT" | "CONFIG" | "RBAC" | "AGENT" | "OUTBOUND_CONNECTION_CHANGE" | "OUTBOUND_HTTP_TRUST_CHANGE" | "ALERT_RULE_CHANGE" | "ALERT_SILENCE_CHANGE" | "DEPLOYMENT" | "LICENSE";
target?: string; target?: string;
detail?: { detail?: {
[key: string]: Record<string, never>; [key: string]: Record<string, never>;
@@ -4825,6 +4892,15 @@ export interface operations {
"*/*": Record<string, never>; "*/*": Record<string, never>;
}; };
}; };
/** @description jarRetentionCount exceeds license cap */
422: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": Record<string, never>;
};
};
}; };
}; };
updateDefaultContainerConfig: { updateDefaultContainerConfig: {
@@ -5805,6 +5881,24 @@ export interface operations {
}; };
}; };
}; };
logout: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Logged out (or no-op if not authenticated) */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
login: { login: {
parameters: { parameters: {
query?: never; query?: never;
@@ -6553,7 +6647,9 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"*/*": components["schemas"]["LicenseInfo"]; "*/*": {
[key: string]: Record<string, never>;
};
}; };
}; };
}; };
@@ -7886,6 +7982,26 @@ export interface operations {
}; };
}; };
}; };
getCapabilities: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Capabilities resolved */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["AuthCapabilitiesResponse"];
};
};
};
};
events: { events: {
parameters: { parameters: {
query?: never; query?: never;
@@ -8062,6 +8178,28 @@ export interface operations {
}; };
}; };
}; };
get_4: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": {
[key: string]: Record<string, never>;
};
};
};
};
};
getTables: { getTables: {
parameters: { parameters: {
query?: never; query?: never;

View File

@@ -91,3 +91,32 @@
width: 100%; width: 100%;
justify-content: center; justify-content: center;
} }
.adminRecoveryBanner {
margin-bottom: 1rem;
}
.adminRecoveryBanner .backToSsoLink {
display: inline-block;
margin-top: 0.5rem;
color: var(--accent);
text-decoration: none;
font-size: 0.875rem;
}
.adminRecoveryBanner .backToSsoLink:hover {
text-decoration: underline;
}
.adminRecoveryLink {
display: inline-block;
margin-top: 0.75rem;
color: var(--text-muted);
font-size: 0.8125rem;
text-decoration: none;
}
.adminRecoveryLink:hover {
color: var(--accent);
text-decoration: underline;
}

View File

@@ -0,0 +1,156 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router';
import type { ReactNode } from 'react';
vi.mock('../api/client', () => ({ api: { GET: vi.fn() } }));
vi.mock('./auth-store', () => ({
useAuthStore: Object.assign(
() => ({ isAuthenticated: false, login: vi.fn(), loading: false, error: null }),
{ setState: vi.fn() }
),
}));
import { api as apiClient } from '../api/client';
import { useAuthStore } from './auth-store';
import { LoginPage } from './LoginPage';
function wrapper(initialEntries: string[]) {
return ({ children }: { children: ReactNode }) => {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return (
<QueryClientProvider client={qc}>
<MemoryRouter initialEntries={initialEntries}>{children}</MemoryRouter>
</QueryClientProvider>
);
};
}
function mockCaps(body: any) {
(apiClient.GET as any).mockImplementation((path: string) => {
if (path === '/auth/capabilities') return Promise.resolve({ data: body, error: null });
if (path === '/auth/oidc/config') return Promise.resolve({
data: {
clientId: 'spa-client',
authorizationEndpoint: 'https://auth.logto.example/oidc/auth',
resource: 'https://api.cameleer.local',
additionalScopes: [],
},
error: null,
});
return Promise.resolve({ data: undefined, error: { message: 'unexpected' } });
});
}
describe('LoginPage', () => {
beforeEach(() => vi.clearAllMocks());
it('SSO primary, no ?local: renders SSO button only and admin-recovery link, no local form', async () => {
mockCaps({
oidc: { enabled: true, providerName: 'Logto', primary: true },
localAccounts: { enabled: true, adminRecoveryOnly: true },
});
render(<LoginPage />, { wrapper: wrapper(['/login']) });
expect(await screen.findByRole('button', { name: /sign in with logto/i })).toBeInTheDocument();
expect(screen.queryByLabelText(/username/i)).toBeNull();
expect(screen.queryByLabelText(/password/i)).toBeNull();
expect(screen.getByRole('link', { name: /admin recovery/i })).toBeInTheDocument();
});
it('SSO primary, ?local present: renders local form with amber recovery banner and back-to-SSO link', async () => {
mockCaps({
oidc: { enabled: true, providerName: 'Logto', primary: true },
localAccounts: { enabled: true, adminRecoveryOnly: true },
});
render(<LoginPage />, { wrapper: wrapper(['/login?local']) });
expect(await screen.findByLabelText(/username/i)).toBeInTheDocument();
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
expect(screen.getByText(/admin recovery/i)).toBeInTheDocument();
expect(screen.getByRole('link', { name: /back to sso/i })).toBeInTheDocument();
});
it('OIDC disabled: renders local form only, no SSO button', async () => {
mockCaps({
oidc: { enabled: false, providerName: '', primary: false },
localAccounts: { enabled: true, adminRecoveryOnly: false },
});
render(<LoginPage />, { wrapper: wrapper(['/login']) });
expect(await screen.findByLabelText(/username/i)).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /sign in with/i })).toBeNull();
expect(screen.queryByText(/admin recovery/i)).toBeNull();
});
it('capabilities request fails: renders degraded local form with warning banner', async () => {
(apiClient.GET as any).mockImplementation((path: string) => {
if (path === '/auth/capabilities') return Promise.resolve({ data: undefined, error: { message: 'fail' } });
return Promise.resolve({ data: undefined, error: { message: 'unexpected' } });
});
render(<LoginPage />, { wrapper: wrapper(['/login']) });
expect(await screen.findByLabelText(/username/i)).toBeInTheDocument();
expect(screen.getByText(/sign-in options couldn't load/i)).toBeInTheDocument();
});
it('SSO button click: navigates to authorize URL WITHOUT prompt=none', async () => {
mockCaps({
oidc: { enabled: true, providerName: 'Logto', primary: true },
localAccounts: { enabled: true, adminRecoveryOnly: true },
});
const originalLocation = window.location;
const hrefSetter = vi.fn();
Object.defineProperty(window, 'location', {
configurable: true,
value: { ...originalLocation, get href() { return ''; }, set href(v: string) { hrefSetter(v); } },
});
try {
render(<LoginPage />, { wrapper: wrapper(['/login']) });
const btn = await screen.findByRole('button', { name: /sign in with logto/i });
fireEvent.click(btn);
await waitFor(() => expect(hrefSetter).toHaveBeenCalled());
const url: string = hrefSetter.mock.calls[0][0];
expect(url).toMatch(/^https:\/\/auth\.logto\.example\/oidc\/auth\?/);
expect(url).not.toMatch(/prompt=none/);
expect(url).toMatch(/response_type=code/);
expect(url).toMatch(/client_id=spa-client/);
expect(url).toMatch(/scope=/);
} finally {
Object.defineProperty(window, 'location', { configurable: true, value: originalLocation });
}
});
it('SSO button click: when /auth/oidc/config fails, button unlocks and error is set', async () => {
const setStateMock = vi.fn();
const useAuthStoreMock = vi.mocked(useAuthStore) as unknown as { setState: typeof setStateMock };
useAuthStoreMock.setState = setStateMock;
(apiClient.GET as any).mockImplementation((path: string) => {
if (path === '/auth/capabilities') return Promise.resolve({
data: { oidc: { enabled: true, providerName: 'Logto', primary: true }, localAccounts: { enabled: true, adminRecoveryOnly: true } },
error: null,
});
if (path === '/auth/oidc/config') return Promise.reject(new Error('network down'));
return Promise.resolve({ data: undefined, error: { message: 'unexpected' } });
});
render(<LoginPage />, { wrapper: wrapper(['/login']) });
const btn = await screen.findByRole('button', { name: /sign in with logto/i });
fireEvent.click(btn);
await waitFor(() => expect(setStateMock).toHaveBeenCalled());
const errorPayload = setStateMock.mock.calls[0][0];
expect(errorPayload.error).toMatch(/OIDC configuration unavailable/i);
// Button should not stay locked in "Redirecting…"
await waitFor(() => expect(btn).not.toHaveTextContent(/redirecting/i));
});
});

View File

@@ -1,21 +1,13 @@
import { type FormEvent, useEffect, useMemo, useRef, useState } from 'react'; import { type FormEvent, useMemo, useState } from 'react';
import { Navigate, useSearchParams } from 'react-router'; import { Link, Navigate, useSearchParams } from 'react-router';
import { useAuthStore } from './auth-store'; import { useAuthStore } from './auth-store';
import { api } from '../api/client'; import { api } from '../api/client';
import { config } from '../config'; import { config } from '../config';
import { useAuthCapabilities } from '../api/queries/auth';
import { Card, Input, Button, Alert, FormField } from '@cameleer/design-system'; import { Card, Input, Button, Alert, FormField } from '@cameleer/design-system';
import brandLogo from '@cameleer/design-system/assets/cameleer-logo.svg'; import brandLogo from '@cameleer/design-system/assets/cameleer-logo.svg';
import styles from './LoginPage.module.css'; import styles from './LoginPage.module.css';
interface OidcInfo {
clientId: string;
authorizationEndpoint: string;
resource?: string;
additionalScopes?: string[];
}
// Logto org scopes required for role mapping in multi-tenant setups.
// Always requested, harmless for non-Logto providers (unknown scopes are ignored per OIDC spec).
const PLATFORM_SCOPES = ['urn:logto:scope:organizations', 'urn:logto:scope:organization_roles']; const PLATFORM_SCOPES = ['urn:logto:scope:organizations', 'urn:logto:scope:organization_roles'];
const SUBTITLES = [ const SUBTITLES = [
@@ -53,66 +45,93 @@ export function LoginPage() {
const subtitle = useMemo(() => SUBTITLES[Math.floor(Math.random() * SUBTITLES.length)], []); const subtitle = useMemo(() => SUBTITLES[Math.floor(Math.random() * SUBTITLES.length)], []);
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [oidc, setOidc] = useState<OidcInfo | null>(null);
const [oidcLoading, setOidcLoading] = useState(false); const [oidcLoading, setOidcLoading] = useState(false);
const autoRedirected = useRef(false);
useEffect(() => { // Mirrors cameleer-saas: when logout sets this flag, render a "Signed out"
api.GET('/auth/oidc/config') // confirmation instead of the regular form. The flag is one-shot — read +
.then(({ data }) => { // cleared on mount.
if (data?.authorizationEndpoint && data?.clientId) { const [signedOut] = useState(() => {
setOidc({ const flag = sessionStorage.getItem('cameleer:signed_out');
clientId: data.clientId, if (flag) sessionStorage.removeItem('cameleer:signed_out');
authorizationEndpoint: data.authorizationEndpoint, return !!flag;
resource: data.resource ?? undefined,
additionalScopes: data.additionalScopes ?? undefined,
}); });
if (data.endSessionEndpoint) {
localStorage.setItem('cameleer-oidc-end-session', data.endSessionEndpoint);
}
}
})
.catch(() => {});
}, []);
// Auto-redirect to OIDC provider for SSO (skip if ?local is in URL) const { data: caps, isError: capsFailed, isLoading: capsLoading } = useAuthCapabilities();
useEffect(() => {
if (oidc && !forceLocal && !autoRedirected.current) {
autoRedirected.current = true;
const redirectUri = `${window.location.origin}${config.basePath}oidc/callback`;
const scopes = ['openid', 'email', 'profile', ...PLATFORM_SCOPES, ...(oidc.additionalScopes || [])];
const params = new URLSearchParams({
response_type: 'code',
client_id: oidc.clientId,
redirect_uri: redirectUri,
scope: scopes.join(' '),
prompt: 'none',
});
if (oidc.resource) params.set('resource', oidc.resource);
window.location.href = `${oidc.authorizationEndpoint}?${params}`;
}
}, [oidc, forceLocal]);
if (isAuthenticated) return <Navigate to="/" replace />; if (isAuthenticated) return <Navigate to="/" replace />;
if (capsLoading) return null;
if (signedOut) {
return (
<div className={styles.page}>
<Card className={styles.card}>
<div className={styles.loginForm}>
<div className={styles.logo}>
<img src={brandLogo} alt="" className={styles.logoImg} />
cameleer
</div>
<p className={styles.subtitle}>You have been signed out successfully.</p>
<Button
variant="primary"
onClick={() => { window.location.replace(`${config.basePath}login`); }}
className={styles.submitButton}
>
Sign in again
</Button>
</div>
</Card>
</div>
);
}
const oidcPrimary = caps?.oidc?.primary === true;
const adminRecoveryOnly = caps?.localAccounts?.adminRecoveryOnly === true;
const providerName = caps?.oidc?.providerName || 'Single Sign-On';
// Render decisions
const showSsoPrimary = oidcPrimary && adminRecoveryOnly && !forceLocal;
const showLocalForm = !oidcPrimary || forceLocal || !adminRecoveryOnly || capsFailed;
const showAdminRecoveryBanner = oidcPrimary && adminRecoveryOnly && forceLocal;
const handleSubmit = (e: FormEvent) => { const handleSubmit = (e: FormEvent) => {
e.preventDefault(); e.preventDefault();
login(username, password); login(username, password);
}; };
const handleOidcLogin = () => { const handleOidcLogin = async () => {
if (!oidc) return;
setOidcLoading(true); setOidcLoading(true);
try {
const { data } = await api.GET('/auth/oidc/config');
if (!data?.authorizationEndpoint || !data?.clientId) {
useAuthStore.setState({ error: 'OIDC configuration unavailable. Try the local form via /login?local.' });
return;
}
if (data.endSessionEndpoint) {
localStorage.setItem('cameleer-oidc-end-session', data.endSessionEndpoint);
}
if (data.clientId) {
localStorage.setItem('cameleer-oidc-client-id', data.clientId);
}
const redirectUri = `${window.location.origin}${config.basePath}oidc/callback`; const redirectUri = `${window.location.origin}${config.basePath}oidc/callback`;
const scopes = ['openid', 'email', 'profile', ...PLATFORM_SCOPES, ...(oidc.additionalScopes || [])]; const scopes = ['openid', 'email', 'profile', ...PLATFORM_SCOPES, ...(data.additionalScopes || [])];
const params = new URLSearchParams({ const params = new URLSearchParams({
response_type: 'code', response_type: 'code',
client_id: oidc.clientId, client_id: data.clientId,
redirect_uri: redirectUri, redirect_uri: redirectUri,
scope: scopes.join(' '), scope: scopes.join(' '),
// Defence-in-depth: even if RP-Initiated Logout did not fully clear
// the IdP session (proxy/cookie edge cases), prompt=login forces the
// IdP to re-prompt for credentials instead of silent re-auth.
// OIDC Core 1.0 §3.1.2.1.
prompt: 'login',
}); });
if (oidc.resource) params.set('resource', oidc.resource); if (data.resource) params.set('resource', data.resource);
window.location.href = `${oidc.authorizationEndpoint}?${params}`; window.location.href = `${data.authorizationEndpoint}?${params}`;
} catch {
useAuthStore.setState({ error: 'OIDC configuration unavailable. Try the local form via /login?local.' });
} finally {
setOidcLoading(false);
}
}; };
return ( return (
@@ -125,33 +144,45 @@ export function LoginPage() {
</div> </div>
<p className={styles.subtitle}>{subtitle}</p> <p className={styles.subtitle}>{subtitle}</p>
{capsFailed && (
<div className={styles.error}>
<Alert variant="warning">Sign-in options couldn't load. Refresh or use the form below.</Alert>
</div>
)}
{showAdminRecoveryBanner && (
<div className={styles.adminRecoveryBanner}>
<Alert variant="warning">
Admin recovery login. Use SSO for normal sign-in.
</Alert>
<Link to="/login" className={styles.backToSsoLink}>← Back to SSO</Link>
</div>
)}
{error && ( {error && (
<div className={styles.error}> <div className={styles.error}>
<Alert variant="error">{error}</Alert> <Alert variant="error">{error}</Alert>
</div> </div>
)} )}
{oidc && ( {showSsoPrimary && (
<>
<div className={styles.socialSection}> <div className={styles.socialSection}>
<Button <Button
variant="secondary" variant="primary"
className={styles.ssoButton} className={styles.ssoButton}
onClick={handleOidcLogin} onClick={handleOidcLogin}
disabled={oidcLoading} disabled={oidcLoading}
type="button" type="button"
> >
{oidcLoading ? 'Redirecting...' : 'Sign in with SSO'} {oidcLoading ? 'Redirecting\u2026' : `Sign in with ${providerName}`}
</Button> </Button>
<Link to="/login?local" className={styles.adminRecoveryLink}>
Admin recovery
</Link>
</div> </div>
<div className={styles.divider}>
<div className={styles.dividerLine} />
<span className={styles.dividerText}>or</span>
<div className={styles.dividerLine} />
</div>
</>
)} )}
{showLocalForm && (
<form className={styles.fields} onSubmit={handleSubmit} aria-label="Sign in" noValidate> <form className={styles.fields} onSubmit={handleSubmit} aria-label="Sign in" noValidate>
<FormField label="Username" htmlFor="login-username"> <FormField label="Username" htmlFor="login-username">
<Input <Input
@@ -187,6 +218,7 @@ export function LoginPage() {
Sign in Sign in
</Button> </Button>
</form> </form>
)}
</div> </div>
</Card> </Card>
</div> </div>

View File

@@ -20,11 +20,6 @@ export function OidcCallback() {
const errorParam = params.get('error'); const errorParam = params.get('error');
if (errorParam) { if (errorParam) {
// prompt=none failed — no session, fall back to login form
if (errorParam === 'login_required' || errorParam === 'interaction_required') {
window.location.replace(`${config.basePath}login?local`);
return;
}
// consent_required — retry without prompt=none so user can grant scopes // consent_required — retry without prompt=none so user can grant scopes
if (errorParam === 'consent_required' && !sessionStorage.getItem('oidc-consent-retry')) { if (errorParam === 'consent_required' && !sessionStorage.getItem('oidc-consent-retry')) {
sessionStorage.setItem('oidc-consent-retry', '1'); sessionStorage.setItem('oidc-consent-retry', '1');
@@ -43,7 +38,7 @@ export function OidcCallback() {
window.location.href = `${data.authorizationEndpoint}?${p}`; window.location.href = `${data.authorizationEndpoint}?${p}`;
} }
}).catch(() => { }).catch(() => {
window.location.replace(`${config.basePath}login?local`); useAuthStore.setState({ error: 'OIDC consent retry failed.', loading: false });
}); });
return; return;
} }
@@ -77,7 +72,7 @@ export function OidcCallback() {
{error && ( {error && (
<> <>
<Alert variant="error">{error}</Alert> <Alert variant="error">{error}</Alert>
<Button variant="secondary" onClick={() => navigate('/login?local')} className={styles.backButton}> <Button variant="secondary" onClick={() => navigate('/login')} className={styles.backButton}>
Back to Login Back to Login
</Button> </Button>
</> </>

View File

@@ -13,7 +13,7 @@ interface AuthState {
login: (username: string, password: string) => Promise<void>; login: (username: string, password: string) => Promise<void>;
loginWithOidcCode: (code: string, redirectUri: string) => Promise<void>; loginWithOidcCode: (code: string, redirectUri: string) => Promise<void>;
refresh: () => Promise<boolean>; refresh: () => Promise<boolean>;
logout: () => void; logout: () => Promise<void>;
} }
function parseRolesFromJwt(token: string): string[] { function parseRolesFromJwt(token: string): string[] {
@@ -140,12 +140,26 @@ export const useAuthStore = create<AuthState>((set, get) => ({
} }
}, },
logout: () => { logout: async () => {
const accessToken = get().accessToken;
const endSessionEndpoint = localStorage.getItem('cameleer-oidc-end-session'); const endSessionEndpoint = localStorage.getItem('cameleer-oidc-end-session');
const idToken = localStorage.getItem('cameleer-oidc-id-token'); const idToken = localStorage.getItem('cameleer-oidc-id-token');
const clientId = localStorage.getItem('cameleer-oidc-client-id');
// Best-effort server-side revocation. Don't fail logout if it errors —
// the SPA-side cleanup below is authoritative for the SPA.
if (accessToken) {
try {
await api.POST('/auth/logout', {});
} catch {
// ignore
}
}
clearTokens(); clearTokens();
localStorage.removeItem('cameleer-oidc-end-session'); localStorage.removeItem('cameleer-oidc-end-session');
localStorage.removeItem('cameleer-oidc-id-token'); localStorage.removeItem('cameleer-oidc-id-token');
localStorage.removeItem('cameleer-oidc-client-id');
set({ set({
accessToken: null, accessToken: null,
refreshToken: null, refreshToken: null,
@@ -154,17 +168,24 @@ export const useAuthStore = create<AuthState>((set, get) => ({
isAuthenticated: false, isAuthenticated: false,
error: null, error: null,
}); });
const loginUrl = `${config.basePath}login?local`;
// Tell the upcoming /login render that this is a post-logout landing,
// not a fresh visit. Mirrors cameleer-saas ui/src/auth/useAuth.ts.
sessionStorage.setItem('cameleer:signed_out', '1');
const localLoginUrl = `${config.basePath}login`;
if (endSessionEndpoint && idToken) { if (endSessionEndpoint && idToken) {
// OIDC RP-Initiated Logout 1.0: top-level navigation, NOT fetch.
// Cross-origin fetch never clears the IdP's session cookie.
const params = new URLSearchParams({ const params = new URLSearchParams({
id_token_hint: idToken, id_token_hint: idToken,
post_logout_redirect_uri: `${window.location.origin}${config.basePath}login?local`, post_logout_redirect_uri: `${window.location.origin}${config.basePath}login`,
});
fetch(`${endSessionEndpoint}?${params}`, { mode: 'no-cors' }).finally(() => {
window.location.href = loginUrl;
}); });
if (clientId) params.set('client_id', clientId);
window.location.replace(`${endSessionEndpoint}?${params}`);
} else { } else {
window.location.href = loginUrl; window.location.replace(localLoginUrl);
} }
}, },
})); }));

View File

@@ -19,8 +19,9 @@ type Fixtures = {
export const test = base.extend<Fixtures>({ export const test = base.extend<Fixtures>({
loggedIn: [ loggedIn: [
async ({ page }, use) => { async ({ page }, use) => {
// `?local` keeps the login page's auto-OIDC-redirect from firing so the // Navigate to ?local to bypass the SSO-primary page and reach the local
// form-based login works even when an OIDC config happens to be present. // form directly, so the fixture works regardless of whether OIDC is
// configured on the test server.
await page.goto('/login?local'); await page.goto('/login?local');
await page.getByLabel(/username/i).fill(ADMIN_USER); await page.getByLabel(/username/i).fill(ADMIN_USER);
await page.getByLabel(/password/i).fill(ADMIN_PASS); await page.getByLabel(/password/i).fill(ADMIN_PASS);