Files
cameleer-server/docs/superpowers/specs/2026-04-26-auth-harmonization-design.md
hsiegeln a3c0e9aa7f
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 2m37s
CI / docker (push) Successful in 2m32s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 53s
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>
2026-04-26 18:37:00 +02:00

12 KiB

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.

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.

  • 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 NewuseAuthCapabilities() 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.