feat(auth): migrate dotAuth (OAuth/OIDC + SAML) from plugin to core#36228
feat(auth): migrate dotAuth (OAuth/OIDC + SAML) from plugin to core#36228swicken wants to merge 134 commits into
Conversation
Legal RiskThe following dependencies were released under a license that RecommendationWhile merging is not directly blocked, it's best to pause and consider what it means to use this license before continuing. If you are unsure, reach out to your security team or Semgrep admin to address this issue. GPL-2.0 MPL-2.0
|
Bring the dotCMS OAuth plugin into core under com.dotcms.auth.providers.oauth with a new dotOAuth app key. Wires the web interceptor, auto-login filter, REST callback, viewtools, and default YAML template. Rejects plugin REST resources whose @path collides with a migrated core resource.
…o-fixes; regenerate openapi
Absorb SAML configuration into the dotAuth portlet so admins edit OAuth and SAML through a single UI, with OAuth/SAML secrets kept in their existing AppSecrets keys. Strategy pattern via ProtocolHandler keeps DotAuthResource thin. dotsaml-config.yml removed in the final commit once the Okta E2E checkpoint passes.
…rtlet count DotAuthResourceTest mocked the old findAll() but listSites now uses the paginated search() API; SerializationHelperTest expected 51 portlets but the dotAuth portlet entry brought the count to 52.
… id_token_hint Three bugs fixed: 1. autoProvision flag was sent by the UI but never stored or enforced. Added the field to OAuthAppConfig, wired it through OAuthProtocolHandler SECRET_KEYS/BOOLEAN_KEYS, and added a guard in OAuthHelper that rejects unknown users when autoProvision is false. 2. Trusted IdP claim/role settings were silently ignored. The exchange resource matched a trusted IdP but only applied issuer/audience/groupsClaim before passing the global config to resolveOrProvisionUser. Added OAuthAppConfig.withTrustedIdpOverrides() to overlay per-IdP claimEmail, claimFirstName, claimLastName, groupMappings, roleBehavior, defaultRoles, and autoProvision onto the base config. 3. RP-initiated logout sent the access token as id_token_hint. The callback validated the id_token but only stored the access token in session. Now the id_token is stored alongside the access token and used for the getLogoutUrl() call.
…ssuer Two security fixes: 1. The /discover/oidc and /fetch/saml-metadata proxy endpoints accepted arbitrary URLs and passed them directly to CircuitBreakerUrl without scheme, HTTPS, or host validation. A comprehensive fetchJson() with DNS-pinned connections was previously removed in aa87ac0 and replaced with raw CircuitBreakerUrl calls. Added validateProxyUrl() to restore scheme + HTTPS + OAuthSsrfGuard host validation, matching the same standard OAuthAppConfig.validateUrl() applies elsewhere. 2. OAuth user IDs were namespaced only by provider type (always "OIDC" for all OIDC providers), so two trusted IdPs emitting the same subject claim would collide on one dotCMS user row — a confused-deputy that lets the second login authenticate as the first user. Now includes the verified issuer in the namespace (sanitized for ID-safe characters). Backward-compat candidates still try the old no-issuer format so existing single-issuer users aren't stranded.
…lidate SSRF guard
1. Trusted IdP roleBehavior values from the UI (idp-only, static-only,
additive, sync-all, none) were stored as-is in the JSON config but
BuildRolesStrategy.resolve() only accepts Java enum names (IDP,
STATICONLY, STATICADD, ALL, NONE). Every value except "none" silently
fell back to ALL, wiping roles contrary to the admin's saved config.
Added translateRoleBehavior() in normalizeIdpMapValues to convert
UI values to enum names before the overlay.
2. Trusted IdP issuer URLs from the raw JSON config were passed directly
to OIDCProvider without scheme, HTTPS, or SSRF host validation. Added
validateIssuerUrl() check before constructing the provider so trusted
IdP issuers go through the same guard as every other OAuth URL.
3. Consolidated the duplicate SSRF+scheme+HTTPS validation logic into
OAuthSsrfGuard.validateUrl(). DotAuthResource.validateProxyUrl() and
DotAuthOAuthExchangeResource.validateIssuerUrl() now both delegate to
the shared method.
4. Fixed raw <strong> HTML tags rendering as text in the clear-config
confirmation dialog by switching from {{ }} interpolation to
[innerHTML] binding.
…once timing Three OIDC spec compliance and security hardening improvements: 1. JWKS cache now uses Nimbus JWKSourceBuilder with TTL (5 min default) and automatic retry on verification failure. Previously, a stale RemoteJWKSet was cached indefinitely per jwks_uri with no refresh mechanism, so IdP key rotation (Azure AD rotates weekly) caused all logins to fail until the JVM restarted or the cache was evicted. 2. Added at_hash validation per OIDC Core §3.2.2.9. When the token response includes both access_token and id_token, the at_hash claim (left half of the access token hash) is verified to prevent token substitution attacks. Validation is skipped when at_hash is absent (optional in the authorization code flow). 3. Nonce comparison in verifyIdTokenClaims now uses constant-time MessageDigest.isEqual() instead of String.equals(), matching the state parameter check already in OAuthWebInterceptor. Closes a theoretical timing side-channel.
…orrectness Use a timestamp marker (LAST_SAVED_PROTOCOL_AT_KEY) to resolve protocol ambiguity when both OAUTH and SAML rows exist after a failed cleanup, instead of relying on EnumMap iteration order. Also: defer success/clear toasts until async completion, add 'error' status to prevent stale toast display, fix saveSso return value for protocol=none, stop clearing headless config on SSO clear, fix signatureValidationType enum value, and correct back-navigation route depth.
…sanitization Refuse to derive OAuth callback URL from the Host header — require explicit callbackUrl configuration to prevent header injection. Add BoundedOutputStream to CircuitBreakerUrl so OIDC discovery (1MB) and SAML metadata (5MB) fetches cannot exhaust server memory. Fix sanitizeRedirect to allow colons in query strings while still blocking protocol-like colons in the path segment.
…check Front-end SAML logins redirect to content URLs and must not be denied by the back-end role gate. Restrict the no-access check to back-end login paths and reuse SamlWebUtils.isBackEndLoginPage so the back-end path set lives in one place.
- HIGH: add one-time-use replay guard for exchanged id_tokens (token-hash cache); reject re-presentation until exp. Correct misleading nonce Javadoc. - Fix clampToIdpExp dead code (claim exp is a java.util.Date, not a Number). - Bump nimbus-jose-jwt to 9.37.4 (CVE-2025-53864). - Sanitize CRLF in exchange security logs; XML-escape SP-metadata certificate. - Bound OIDCProvider outbound fetches (setMaxResponseBytes); add azp check for multi-audience id_tokens; fail closed on present-but-unverifiable at_hash. - Add dotAuth package to swagger resourcePackages (regenerate openapi.yaml); unify dotAuth tag descriptions; de-duplicate i18n keys; size session/replay caches. - FE: suppress false saved toast on failed post-save reload; remove write-to-void session-TTL/audience/responseType/pkce inputs; delete dead dot-demo. - Tests: OAuthSsrfGuard, exchange CORS/trusted-IdP branches, sanitizeRedirect vectors.
- CircuitBreakerUrl: enforce maxResponseBytes on every consumption path and fail loudly (ResponseSizeLimitExceededException) instead of returning a silently truncated body with a 2xx status - OAuthWebInterceptor: only take over logout for sessions created via OAuth; native/basic/SAML sessions keep the standard logout contract. Route the pre-redirect role gate through AuthAccessDeniedUtil so admins are treated consistently on both flow legs - OIDCProvider: don't emit a dangling '?' on the end-session logout URL - OAuthHelper: deterministic subject resolution (sub/id/user_id/oid, verified email as last resort) instead of a per-login random UUID that broke generic OAuth2 providers after the first login; refuse login when no stable identity can be established - DotSamlResource/SAMLHelper: IdP-initiated logins (no RelayState) by users without back-end access route to '/' when front-end SSO is enabled instead of being 403'd by the defaulted /dotAdmin/ path; centralize enableBackend/enableFrontend semantics in SAMLHelper - DotAuthSessionCacheImpl: make the id_token replay guard's check-then-put atomic; correct the javadoc to state the cache is node-local and document the session-affinity/distributed-provider requirement - dot-auth portlet: round-trip idpName, sPEndpointHostname, signRequests, revocationUrl and groupsUrl through fromView/toPayload so a UI save can no longer blank a working SP hostname or delete stored OAuth secrets - OIDCProviderClaimValidationTest: align the multi-audience test with the azp hardening (was failing on HEAD)
idTokenFingerprint returned null on NoSuchAlgorithmException, which made registerExchangeTokenUse permissive — skipping replay protection for that request. SHA-256 is JVM-mandatory, so this was a documented fail-open on a dead branch. Narrow the catch and throw instead, matching the existing OAuthCrypto.pkceChallengeS256 behavior.
Mirror Nimbus DefaultJWTProcessor's 60s clock-skew allowance in the explicit exp gate so it never rejects a token Nimbus already accepted.
52fc881 to
1bb0b50
Compare
🤖 Bedrock Review —
|
|
Claude finished @swicken's task in 3m 21s —— View job Rollback Safety Analysis
Pull Request Unsafe to Rollback Category: H-1 — One-Way Data Migration on
Category: H-8 — New VTL Viewtools Added to
|
|
Pull Request Unsafe to Rollback!!! Category: H-1 — One-Way Data Migration on
Category: H-8 — New VTL Viewtools Added to
|
Closes #35555
Proposed Changes
Migrates the OAuth 2.0 / OIDC provider from the legacy dotOAuth plugin into core, adds SAML support as a first-class protocol alongside OAuth, introduces a headless token-exchange flow for front-end SPAs, and replaces the old dotsaml-config / dotOAuth app-key editors with a unified dotAuth portlet under Settings.
Backend (
dotCMS/src)REST API —
/v1/dotauthDotAuthResource, DTOs, wired intoDotRestApplication)..well-known/openid-configurationand returns parsed issuer, endpoints, JWKS URI, and signing algorithms.Protocol layer
DotAuthProtocolenum +ProtocolHandlerstrategy interface.OAuthProtocolHandler— extracted from the legacy plugin; hardened OIDCisscheck,algvalidation, session binding.SamlProtocolHandler— new; own secret-key set, metadata endpoint, custom attribute mapping.dotOAuth→dotAuth.Headless token exchange
/v1/dotauth/exchange) — accepts an authorization code from an SPA, validates against the OIDC provider, provisions or resolves the user, and returns a dotCMS session-ref bearer token.DotAuthSessionmodel + cache for session-ref tokens;DotAuthSessionCredentialProcessorwired into the filter chain.BuildRolesStrategy— configurable role sync (merge / replace / none) on each login.Security hardening
OAuthSsrfGuard).id_token.Portlet registration
Task260420AddDotAuthPortletToMenu) registers the dotAuth portlet in the Settings layout.portlet.xmlregistration;/apps/dotsaml-configredirects to the dotAuth portlet; dotsaml-config hidden from the Apps grid.Frontend (
core-web)libs/portlets/dot-auth(new Nx library)protocol, list + config SignalStores.Modernization
p-inputSwitch→p-toggleswitch,pTextarea+TextareaModule), Tailwind v4 var-shorthand normalization.ANGULAR_STANDARDS(signals,@if/@for, OnPush).Testing
DotAuthResource, protocol handlers, exchange guard paths, role-strategy, OIDC claim validation, SSRF guard, mapper round-trips.Rollback notes
The startup task inserts a
dotAuthportlet entry intocms_layouts_portletsand shifts existing portlet order values. On rollback to N−1 this entry becomes orphaned. Manual cleanup required:A CDN purge of the Angular admin bundle may also be needed if a caching proxy is in front of the admin UI.
Checklist
Stats
126 files changed, ~16k LOC added