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>
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.
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) landsoperatorandviewerusers 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 byServerApiClient.pushOidcConfigafter 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=trueand Logto issuer →enabled:true, providerName:"Logto", primary:true, adminRecoveryOnly:true. - OIDC row with
enabled=false→ behaves like absent.
- No OIDC row in DB →
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 +
?localin 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.
- Capabilities returns
Manual smoke (the original bug)
- Provision a fresh tenant via cameleer-saas (
/admin/tenants/new). - In a private window with no Logto session, navigate to the tenant's server dashboard URL.
- Expected: lands on Logto's hosted sign-in page directly (not on a local form trap).
- Sign in via Logto. Expected: redirected back to server dashboard, authenticated.
- 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_PLATFORMURLenv 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=nonesemantics. 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.