docs(auth): harmonization design — login routing capability model
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) <noreply@anthropic.com>
This commit is contained in:
179
docs/superpowers/specs/2026-04-26-auth-harmonization-design.md
Normal file
179
docs/superpowers/specs/2026-04-26-auth-harmonization-design.md
Normal file
@@ -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 `<server-url>/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.
|
||||
Reference in New Issue
Block a user