# 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.
