From a3c0e9aa7f883c4bfd9be4379d5c26e966fc29ff Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 26 Apr 2026 18:37:00 +0200 Subject: [PATCH] =?UTF-8?q?docs(auth):=20harmonization=20design=20?= =?UTF-8?q?=E2=80=94=20login=20routing=20capability=20model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captures the decision to gate login UX on capabilities (no SaaS-mode flag), drop prompt=none from the primary OIDC flow per RFC 9700 §4.4, and keep ?local as the explicit admin-recovery escape hatch. MFA enrollment / enforcement and password reset for local accounts are explicitly deferred and tracked in issue #154. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-04-26-auth-harmonization-design.md | 179 ++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-26-auth-harmonization-design.md diff --git a/docs/superpowers/specs/2026-04-26-auth-harmonization-design.md b/docs/superpowers/specs/2026-04-26-auth-harmonization-design.md new file mode 100644 index 00000000..c4dfc10f --- /dev/null +++ b/docs/superpowers/specs/2026-04-26-auth-harmonization-design.md @@ -0,0 +1,179 @@ +# Auth Harmonization (Login Routing) Design + +**Date:** 2026-04-26 +**Status:** Proposed +**Scope:** Login routing UX across three deployment topologies — standalone-with-built-in, standalone-with-arbitrary-OIDC, and SaaS-managed-with-Logto. Fixes the `prompt=none → ?local` trap that breaks fresh SaaS-provisioned tenants. +**Out of scope:** MFA enrollment, MFA enforcement filter, password reset for local accounts. See [issue #154](https://gitea.siegeln.net/cameleer/cameleer-server/issues/154). + +## 1. Problem + +A user on a freshly SaaS-provisioned tenant server, with `OidcConfig` correctly pushed by `ServerApiClient.pushOidcConfig`, navigates to the dashboard with no Logto session and is unable to sign in with their Logto credentials. Clicking "Sign in with SSO" works. + +### Root cause + +`ui/src/auth/LoginPage.tsx:80-93` auto-redirects to the OIDC authorize endpoint with `prompt=none`. Per OIDC Core §3.1.2.1, `prompt=none` returns `error=login_required` whenever the IdP has no active session. `ui/src/auth/OidcCallback.tsx:24-26` handles this error by redirecting to `/login?local`, which suppresses the auto-redirect and renders the local username/password form. The user has no visual cue that this form is for built-in accounts only — they enter their Logto credentials, the server tries env-var admin and the DB `password_hash` table, neither matches, and 401 is returned. + +This contradicts OAuth 2.0 Security BCP (RFC 9700 §4.4): `prompt=none` is for silent re-auth only, not for primary login UX. + +### Related observations + +- The server already supports OIDC against arbitrary providers (`OidcAuthController` + `OidcConfigRepository`), with rich claim mapping. SaaS-managed Logto is just one possible IdP. +- The SaaS-side identity model (`cameleer-saas/src/main/java/net/siegeln/cameleer/saas/config/CLAUDE.md`) lands `operator` and `viewer` users directly on the cameleer-server dashboard. They never reach the SaaS tenant portal. +- Two OIDC config sources coexist: env vars (`CAMELEER_SERVER_SECURITY_OIDC_*` → `SecurityProperties.Oidc`, used as resource-server JWT decoder) and the database row (`OidcConfigRepository`, used by the interactive login flow). The DB row is populated by `ServerApiClient.pushOidcConfig` after provisioning. They are independent and can disagree. + +## 2. Decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Detection model | **Capability-gated** — no "SaaS-managed" mode flag | Server stays IdP-agnostic. Standalone-with-Keycloak and SaaS-managed-with-Logto get identical login routing; the only differences are downstream features (deferred). | +| Login UX when OIDC is configured | OIDC is **primary**, local form is **admin-recovery only** behind `?local` | Matches Universal Login (Auth0/Logto/Okta), Grafana `disable_login_form`, GitHub Enterprise / Atlassian Access patterns. RFC 9700 §4.4 explicitly endorses this. | +| Silent SSO (`prompt=none`) | **Removed** from primary flow | Per RFC 9700 §4.4 it's only safe after evidence of an existing session. We have no such evidence today. Re-add later behind a "user has signed in before" cookie if the extra click bothers anyone. | +| `?local` escape hatch | **Kept** — explicit admin recovery, discoverable via small link on SSO-primary page | Decided in design review (option 9a). Avoids the failure mode where a misconfigured OIDC bricks the server with no recovery path. | +| Capability discovery | New endpoint `GET /api/v1/auth/capabilities` | Single source of truth for the SPA. Replaces the today's "infer from `/auth/oidc/config` 200/404" dance. Future deferred work (MFA enrollment URL, password-reset URL) extends this same endpoint. | + +## 3. Capability endpoint + +``` +GET /api/v1/auth/capabilities (unauthenticated, permit-all) + +200 OK +{ + "oidc": { + "enabled": true, + "providerName": "Logto", + "primary": true + }, + "localAccounts": { + "enabled": true, + "adminRecoveryOnly": true + } +} +``` + +### Field semantics + +| Field | Source | Meaning | +|---|---|---| +| `oidc.enabled` | `OidcConfigRepository.find().enabled` | Whether OIDC interactive login is configured and turned on. | +| `oidc.providerName` | Best-effort label from issuer hostname | Display string for the SSO button. `*.logto.*` → `"Logto"`, `*keycloak*` → `"Keycloak"`, `*.auth0.com` → `"Auth0"`, `*okta*` → `"Okta"`, fallback `"Single Sign-On"`. | +| `oidc.primary` | `oidc.enabled` | When true, the SPA renders the SSO button as the primary CTA and hides the local form unless `?local` is present. | +| `localAccounts.enabled` | Always `true` for now | Reserved for a future admin toggle ("disable local form entirely"). Hard-coded `true` in this iteration. | +| `localAccounts.adminRecoveryOnly` | `oidc.primary` | When true, the SPA gates the local form behind `?local` and renders an amber "Admin recovery" banner. | + +### Failure mode + +If the endpoint returns non-200 (network error, 500), the SPA degrades gracefully: assume `oidc.enabled=false, localAccounts.enabled=true`, render local-form-only with a small banner: "Sign-in options couldn't load. Refresh or use the form below." + +## 4. SPA behaviour + +### `LoginPage.tsx` + +``` +mount → fetch GET /api/v1/auth/capabilities + ↓ + branch on caps: + + oidc.primary && localAccounts.adminRecoveryOnly: + if ?local in URL: + render local form + + amber banner "Admin recovery login. Use SSO for normal sign-in." + + "Back to SSO →" link + else: + render big "Sign in with {oidc.providerName}" button + + small "Admin recovery →" link to ?local + (NO local form) + + oidc.primary && !adminRecoveryOnly: [reserved — not used in this iteration] + render SSO button + local form + + !oidc.primary: + render local form only + + caps load failed: + render local form + degraded banner +``` + +The SSO button click builds the authorize URL with `response_type=code, client_id, redirect_uri, scope=openid email profile + PLATFORM_SCOPES + additionalScopes`. **Without** `prompt=none`. The user always lands on the IdP's hosted login page; if a session already exists at the IdP, the IdP itself decides to silent-redirect back. + +### `OidcCallback.tsx` + +The fallback `window.location.replace('/login?local')` on `error=login_required` / `interaction_required` is **deleted**. Those error codes can no longer arise from our normal flow. If they do arise (e.g., a stale tab carrying an in-flight request), we show the error message with a "Try again" button — never trap on the local form. + +The `consent_required` retry path is unchanged (it correctly re-issues the authorize request without `prompt=none`). + +## 5. Backend changes + +| Component | Change | +|---|---| +| `cameleer-server-app/.../security/AuthCapabilitiesController.java` | **New** — single GET endpoint returning the capabilities payload. Class-level `@RequestMapping("/api/v1/auth")`. Method-level `@GetMapping("/capabilities")`. | +| `cameleer-server-app/.../dto/AuthCapabilitiesResponse.java` | **New** — DTO record with two nested records: `Oidc(boolean enabled, String providerName, boolean primary)` and `LocalAccounts(boolean enabled, boolean adminRecoveryOnly)`. | +| `cameleer-server-app/.../security/OidcProviderNameDeriver.java` | **New** — pure utility `deriveName(String issuerUri) → String`. Pattern-matches the issuer host and returns the display label. Unit-tested for `*.logto.*`, `*keycloak*`, `*.auth0.com`, `*okta*`, fallback. | +| `cameleer-server-app/.../security/SecurityConfig.java` | **No change** — `/api/v1/auth/**` is already permit-all. New endpoint inherits. | + +The controller depends on `OidcConfigRepository` only. No new dependencies, no new beans. + +## 6. Frontend changes + +| Component | Change | +|---|---| +| `ui/src/api/queries/auth.ts` | **New** — `useAuthCapabilities()` TanStack Query hook with `staleTime: Infinity` (server config doesn't change mid-session). | +| `ui/src/auth/LoginPage.tsx` | Replace the **on-mount auto-redirect** block with a capabilities-driven render switch (per §4). Remove the `autoRedirected` ref. Add the amber admin-recovery banner. The existing `GET /auth/oidc/config` call stays — it still provides `clientId`, `authorizationEndpoint`, `resource`, and `additionalScopes` for the SSO button click handler. The capabilities endpoint decides **which UI to render**; the OIDC config endpoint supplies **what the SSO click sends**. The two are complementary, not replacements. The `/auth/oidc/config` fetch can move from on-mount to lazy-on-click since it's only needed when the user actually clicks SSO. | +| `ui/src/auth/OidcCallback.tsx` | Delete the `login_required` / `interaction_required` → `?local` redirect block (lines 22-27). Replace with a generic error-with-retry render. | +| `ui/src/auth/LoginPage.module.css` | Add styles for the admin-recovery banner and the secondary `?local` link. | +| `ui/src/api/openapi.json`, `ui/src/api/schema.d.ts` | Regenerated via `npm run generate-api:live` after controller is up. | + +## 7. Documentation changes + +| File | Change | +|---|---| +| `.claude/rules/app-classes.md` | Add `AuthCapabilitiesController` to the auth section under "Other (flat)" or a new "Auth (flat)" subsection alongside `UiAuthController` / `OidcAuthController`. | +| `CLAUDE.md` | Add a one-paragraph note under "Security" explaining the capability-gated login model and the `?local` admin-recovery escape hatch. | + +## 8. Testing + +### Backend unit tests + +- `AuthCapabilitiesControllerTest`: + - No OIDC row in DB → `{ oidc: {enabled:false, providerName:"", primary:false}, localAccounts: {enabled:true, adminRecoveryOnly:false} }`. + - OIDC row with `enabled=true` and Logto issuer → `enabled:true, providerName:"Logto", primary:true, adminRecoveryOnly:true`. + - OIDC row with `enabled=false` → behaves like absent. +- `OidcProviderNameDeriverTest`: + - `https://auth.logto.example/` → "Logto" + - `https://keycloak.example/realms/cameleer` → "Keycloak" + - `https://example.auth0.com/` → "Auth0" + - `https://dev-123.okta.com/` → "Okta" + - `https://idp.example.com/` → "Single Sign-On" + +### Frontend unit tests + +- `LoginPage.test.tsx` (Vitest + React Testing Library): + - Capabilities returns `oidc.primary=true, adminRecoveryOnly=true`, no `?local` → SSO button visible, no local form. + - Same caps + `?local` in URL → local form visible, amber banner, "Back to SSO" link visible. + - Capabilities returns `oidc.enabled=false` → only local form, no SSO button. + - Capabilities request fails → degraded local form + warning banner. + +### Manual smoke (the original bug) + +1. Provision a fresh tenant via cameleer-saas (`/admin/tenants/new`). +2. In a private window with no Logto session, navigate to the tenant's server dashboard URL. +3. **Expected:** lands on Logto's hosted sign-in page directly (not on a local form trap). +4. Sign in via Logto. **Expected:** redirected back to server dashboard, authenticated. +5. Sign out. Try `/login?local`. **Expected:** local form rendered with amber admin-recovery banner. + +## 9. Out of scope + +These are intentionally not in this spec; they are tracked separately: + +- MFA enrollment + enforcement (issue #154). +- Password reset for local accounts (would require SMTP wiring; create separate issue when needed). +- TOTP for local accounts. +- Per-tenant MFA policy callback (`GET /platform/api/tenant/{slug}/mfa-policy`). +- `CAMELEER_SERVER_SAAS_PLATFORMURL` env var (not needed for login routing alone). + +## 10. References + +- RFC 9700 (OAuth 2.0 Security Best Current Practice), §4.4 on `prompt=none`. +- OIDC Core 1.0, §3.1.2.1 on `prompt=none` semantics. +- `cameleer-saas/docs/superpowers/specs/2026-04-26-password-reset-mfa-design.md` — sibling SaaS spec. +- `cameleer-saas/docs/superpowers/specs/2026-04-26-server-mfa-handoff.md` — original handoff doc whose enforcement/enrollment scope was deferred. +- Industry pattern references: Auth0 Universal Login; Grafana `auth.disable_login_form` / `oauth_auto_login`; GitHub Enterprise SAML SSO enforcement; Atlassian Access.