Skip to content

feat(auth): migrate dotAuth (OAuth/OIDC + SAML) from plugin to core#36228

Open
swicken wants to merge 138 commits into
mainfrom
feat/dotauth-core-migration
Open

feat(auth): migrate dotAuth (OAuth/OIDC + SAML) from plugin to core#36228
swicken wants to merge 138 commits into
mainfrom
feat/dotauth-core-migration

Conversation

@swicken

@swicken swicken commented Jun 18, 2026

Copy link
Copy Markdown
Member

Closes #35555

Supersedes #35421 (same branch; could not be reopened).

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/dotauth

  • Full CRUD for per-host dotAuth configs (DotAuthResource, DTOs, wired into DotRestApplication).
  • Config bundle import/export endpoints (encrypted Apps export file, dotAuth-only filter on import).
  • OIDC discovery proxy — fetches .well-known/openid-configuration and returns parsed issuer, endpoints, JWKS URI, and signing algorithms.
  • Session-ref revocation endpoint (flushes the dotAuth sessionRef cache).

Protocol layer

  • DotAuthProtocol enum + ProtocolHandler strategy interface.
  • OAuthProtocolHandler — extracted from the legacy plugin; hardened OIDC iss check, alg validation, session binding.
  • SamlProtocolHandler — new; own secret-key set, metadata endpoint, custom attribute mapping.
  • Dual-key read/write/delete with case-insensitive host-id lookup; key rename dotOAuthdotAuth.

Headless token exchange

  • OAuth exchange endpoint (/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.
  • DotAuthSession model + cache for session-ref tokens; DotAuthSessionCredentialProcessor wired into the filter chain.
  • BuildRolesStrategy — configurable role sync (merge / replace / none) on each login.

Security hardening

  • SSRF DNS-rebinding prevention on the OIDC discovery proxy (shared OAuthSsrfGuard).
  • 60s clock-skew tolerance + fail-closed replay-fingerprint check on OIDC id_token.
  • Admin-only guard on session-ref revocation.
  • No OIDC validation details leaked in error responses.
  • OAuth callback URL auto-derived from request (no user-supplied redirect target).
  • Response size limits and redirect sanitization on the OAuth callback.

Portlet registration

  • Startup task (Task260420AddDotAuthPortletToMenu) registers the dotAuth portlet in the Settings layout.
  • portlet.xml registration; /apps/dotsaml-config redirects to the dotAuth portlet; dotsaml-config hidden from the Apps grid.

Frontend (core-web)

libs/portlets/dot-auth (new Nx library)

  • Data-access service, discriminated-union models on protocol, list + config SignalStores.
  • List view — protocol column, composed status tag, per-host CRUD.
  • SSO config page — protocol toggle (OAuth / SAML), OIDC discovery auto-fill, SAML fieldsets (metadata URL/fetch, custom attributes, SP keypair auto-generation, metadata download), auto-provision and role-sync strategy controls.
  • Headless config page — separated from SSO; system-level headless security settings with own app key.
  • Config bundle import/export UI.
  • Shell, routes, and i18n keys.

Modernization

  • PrimeNG migration (p-inputSwitchp-toggleswitch, pTextarea + TextareaModule), Tailwind v4 var-shorthand normalization.
  • Components aligned with ANGULAR_STANDARDS (signals, @if/@for, OnPush).
  • Regenerated OpenAPI client.

Testing

  • Backend: JUnit 5 unit tests for DotAuthResource, protocol handlers, exchange guard paths, role-strategy, OIDC claim validation, SSRF guard, mapper round-trips.
  • Frontend: Spectator specs for list component, edit dialog, SignalStores, SAML + protocol-switch paths, mapper round-trips.

Rollback notes

The startup task inserts a dotAuth portlet entry into cms_layouts_portlets and shifts existing portlet order values. On rollback to N−1 this entry becomes orphaned. Manual cleanup required:

DELETE FROM cms_layouts_portlets WHERE portlet_id = 'dotAuth';

A CDN purge of the Angular admin bundle may also be needed if a caching proxy is in front of the admin UI.


Checklist

  • Tests — backend unit + frontend Spectator coverage across all major paths
  • Translations — i18n keys for dotAuth labels, protocols, fieldsets, and error states
  • Security — SSRF prevention, OIDC hardening (clock skew, replay), no leaked validation details, admin-only endpoints, no hardcoded secrets

Stats

126 files changed, ~16k LOC added

@semgrep-code-dotcms-test

Copy link
Copy Markdown
Contributor

Legal Risk

The following dependencies were released under a license that
has been flagged by your organization for consideration.

Recommendation

While 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

swicken added 28 commits June 18, 2026 16:19
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.
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.
swicken added 9 commits June 18, 2026 16:20
…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.
@swicken swicken force-pushed the feat/dotauth-core-migration branch from 52fc881 to 1bb0b50 Compare June 18, 2026 20:22
@github-actions github-actions Bot added Area : Backend PR changes Java/Maven backend code Area : Frontend PR changes Angular/TypeScript frontend code labels Jun 18, 2026
@github-actions

github-actions Bot commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

🤖 Bedrock Review — deepseek.v3.2

[🟡 Medium] core-web/apps/dotcms-ui/src/app/portlets/dot-apps/dot-apps-import-export-dialog/store/dot-apps-import-export-dialog.store.ts:65_importSuccessSubject is exposed as a public property (store._importSuccessSubject). This breaks encapsulation and allows external code to call next()/complete()/error() on the subject, which is an internal implementation detail. It should be kept private.

[🟡 Medium] core-web/apps/dotcms-ui/src/app/portlets/dot-apps/dot-apps-import-export-dialog/store/dot-apps-import-export-dialog.store.ts:77 — Method openExport signature changed to accept DotApp | null. The comment on line 88 says "the store handles null to mean 'all apps'", but the store method's logic for handling null is not shown in the diff. Ensure the backend API call correctly handles a null app key for a full export.

[🟡 Medium] core-web/apps/dotcms-ui/src/app/portlets/dot-apps/dot-apps-list/dot-apps-list.component.ts:154 — The filterApps method subscribes but does not handle errors. If the dotAppsService.get call fails, the error will be uncaught, potentially breaking the UI. Add error handling (e.g., log and reset state).

[🟡 Medium] core-web/libs/data-access/src/lib/dot-auth/dot-auth.service.ts:99downloadSamlMetadata uses window.open without any user interaction context (called from a component). Browsers may block this as a pop-up. Consider triggering this from a direct user action (like a button click) or use a safer method.

[🟡 Medium] core-web/libs/data-access/src/lib/dot-auth/dot-auth.service.ts:112exportBundle returns a Promise but the rest of the service methods return Observable. This creates an inconsistent API pattern. Consider aligning it to return an Observable for consistency, or ensure the mixed pattern is documented and intentional.

[🟠 High] core-web/libs/data-access/src/lib/dot-auth/dot-auth.service.ts:130importBundle constructs a FormData with a JSON string under key "json". The backend endpoint /api/v1/dotauth/import likely expects the password as a form field, not a JSON string. This could cause a server-side parsing error. Verify the expected request format matches the backend.

[🟡 Medium] core-web/libs/portlets/dot-auth/src/lib/dot-auth-config/components/dot-auth-headless-section.component.ts:69toggleIdp toggles expansion based on index, but if an IdP is removed, the indices shift, which could cause the wrong IdP to be expanded. Consider tracking expansion by a stable ID (like idp.id) instead of index.

[🟡 Medium] core-web/libs/portlets/dot-auth/src/lib/dot-auth-config/components/dot-auth-oidc-connection.component.ts:33isSecretStored checks for hardcoded '****' and DOT_AUTH_HIDDEN_SECRET_MASK. This logic is duplicated from the backend secret masking. Ensure the mask value is synchronized and documented as a constant shared with the backend to avoid drift.

[🟡 Medium] core-web/libs/portlets/dot-auth/src/lib/dot-auth-config/components/dot-auth-oidc-connection.component.ts:41error method accesses this.errors()[oidc.${field}]. The error mapping relies on a dotted path convention. Ensure the parent component provides errors in exactly this format, or this could fail silently.

[🟡 Medium] core-web/libs/portlets/dot-auth/src/lib/dot-auth-config/components/dot-auth-provisioning.component.html (line not in diff) — The template uses [ngModel] and (ngModelChange) but the corresponding component logic (onChange) is not shown in the diff. Ensure the component has the necessary method to handle the change events emitted by the template.


Run: #27967632129 · tokens: in: 22400 · out: 934 · total: 23334

@claude

claude Bot commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Claude finished @swicken's task in 3m 2s —— View job


Pull Request Unsafe to Rollback!!!


Category: H-1 — One-Way Data Migration on cms_layouts_portlets

  • Risk Level: 🟡 MEDIUM
  • Why it's unsafe: Task260420AddDotAuthPortletToMenu performs two writes that persist after rollback to N-1: (1) it INSERTs a new dotAuth row into cms_layouts_portlets, which becomes an orphaned row on N-1 (N-1 has no dotAuth portlet and will ignore it, but the row stays); (2) it UPDATEs every portlet whose portlet_order ≥ appsOrder + 1 by incrementing the order by 1 — this is a one-way transformation on existing rows. On rollback to N-1, the shifted portlet_order values persist, so portlets that were after the apps entry appear at +1 from their pre-migration positions. Neither write can be automatically undone by reverting the binary.
  • Code that makes it unsafe:
    • File: dotCMS/src/main/java/com/dotmarketing/startup/runonce/Task260420AddDotAuthPortletToMenu.java
    • Lines 57–61 (the permanent portlet_order shift):
      private static final String SQL_SHIFT_ORDER =
              "UPDATE cms_layouts_portlets SET portlet_order = portlet_order + 1 "
              + "WHERE layout_id = ? AND portlet_order >= ?";
    • Lines 60–63 (the INSERT that becomes orphaned on rollback):
      private static final String SQL_INSERT =
              "INSERT INTO cms_layouts_portlets(id, layout_id, portlet_id, portlet_order) "
              + "VALUES (?, ?, ?, ?)";
  • Alternative: The PR already documents the orphaned row cleanup SQL (DELETE FROM cms_layouts_portlets WHERE portlet_id = 'dotAuth'). The portlet_order shift is NOT addressed — on rollback the admin Settings menu portlets remain shifted by 1 position from their pre-migration values. To make this fully safe, the task should record the pre-migration portlet_order values or use a reversible approach (no separate manual step is currently documented for the order shift).

Category: H-8 — New VTL Viewtools Added to toolbox.xml

  • Risk Level: 🟢 LOW
  • Why it's unsafe: Two new viewtools are registered in WEB-INF/toolbox.xml$oauth (OAuthTool) and $oauthToken (OAuthTokenTool). N-1 does not have these keys in its toolbox. Any Velocity template (stored in the DB or theme filesystem) that adopts $oauth.isConfigured() or $oauthToken.getAccessToken() during the N release cycle will fail to resolve after rollback, rendering the affected template block blank or erroring. No core dotCMS VTL templates are co-migrated in this PR's diff, so the risk is conditional on customer templates adopting these new keys.
  • Code that makes it unsafe:
    • File: dotCMS/src/main/webapp/WEB-INF/toolbox.xml
    • Lines 340–349:
      <tool>
          <key>oauth</key>
          <scope>request</scope>
          <class>com.dotcms.auth.providers.oauth.viewtool.OAuthTool</class>
      </tool>
      <tool>
          <key>oauthToken</key>
          <scope>request</scope>
          <class>com.dotcms.auth.providers.oauth.viewtool.OAuthTokenTool</class>
      </tool>
  • Alternative: No change needed in this PR since no templates are co-migrated. Document in release notes that any customer template using $oauth or $oauthToken will fail on rollback to N-1 and must be reverted alongside the binary.

@claude

claude Bot commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Pull Request Unsafe to Rollback!!!


Category: H-1 — One-Way Data Migration on cms_layouts_portlets

  • Risk Level: 🟡 MEDIUM
  • Why it's unsafe: Task260420AddDotAuthPortletToMenu performs two writes that persist after rollback to N-1: (1) it INSERTs a new dotAuth row into cms_layouts_portlets, which becomes an orphaned row on N-1 (N-1 has no dotAuth portlet and will ignore it, but the row stays); (2) it UPDATEs every portlet whose portlet_order ≥ appsOrder + 1 by incrementing the order by 1 — this is a one-way transformation on existing rows. On rollback to N-1, the shifted portlet_order values persist, so portlets that were after the apps entry appear at +1 from their pre-migration positions. Neither write can be automatically undone by reverting the binary.
  • Code that makes it unsafe:
    • File: dotCMS/src/main/java/com/dotmarketing/startup/runonce/Task260420AddDotAuthPortletToMenu.java
    • Lines 57–61 (the permanent portlet_order shift):
      private static final String SQL_SHIFT_ORDER =
              "UPDATE cms_layouts_portlets SET portlet_order = portlet_order + 1 "
              + "WHERE layout_id = ? AND portlet_order >= ?";
    • Lines 60–63 (the INSERT that becomes orphaned on rollback):
      private static final String SQL_INSERT =
              "INSERT INTO cms_layouts_portlets(id, layout_id, portlet_id, portlet_order) "
              + "VALUES (?, ?, ?, ?)";
  • Alternative: The PR already documents the orphaned row cleanup SQL (DELETE FROM cms_layouts_portlets WHERE portlet_id = 'dotAuth'). The portlet_order shift is NOT addressed — on rollback the admin Settings menu portlets remain shifted by 1 position from their pre-migration values. To make this fully safe, the task should record the pre-migration portlet_order values or use a reversible approach (no separate manual step is currently documented for the order shift).

Category: H-8 — New VTL Viewtools Added to toolbox.xml

  • Risk Level: 🟢 LOW
  • Why it's unsafe: Two new viewtools are registered in WEB-INF/toolbox.xml$oauth (OAuthTool) and $oauthToken (OAuthTokenTool). N-1 does not have these keys in its toolbox. Any Velocity template (stored in the DB or theme filesystem) that adopts $oauth.isConfigured() or $oauthToken.getAccessToken() during the N release cycle will fail to resolve after rollback, rendering the affected template block blank or erroring. No core dotCMS VTL templates are co-migrated in this PR's diff, so the risk is conditional on customer templates adopting these new keys.
  • Code that makes it unsafe:
    • File: dotCMS/src/main/webapp/WEB-INF/toolbox.xml
    • Lines 340–349:
      <tool>
          <key>oauth</key>
          <scope>request</scope>
          <class>com.dotcms.auth.providers.oauth.viewtool.OAuthTool</class>
      </tool>
      <tool>
          <key>oauthToken</key>
          <scope>request</scope>
          <class>com.dotcms.auth.providers.oauth.viewtool.OAuthTokenTool</class>
      </tool>
  • Alternative: No change needed in this PR since no templates are co-migrated. Document in release notes that any customer template using $oauth or $oauthToken will fail on rollback to N-1 and must be reverted alongside the binary.

swicken added 2 commits June 22, 2026 10:49
A blank callbackUrl now derives scheme://host[:port] from the request (default
ports omitted), the same way SAML derives its endpoints, instead of refusing to
redirect. This is required for a global config serving multiple sites, where each
site's callback host differs and a single stored value can't cover them all. An
explicit callbackUrl still acts as an override for proxy edge cases.
…y controls

Drops the audit-log anchor in the headless section's emergency controls and its
now-orphaned i18n key (dotauth.action.audit-log).
…th login

The post-login redirect target now prefers dotCMS's server-side request cache
(session REDIRECT_AFTER_LOGIN, set by core's login-required logic for the real
protected page), falling back to the referrer param and then the request URI.
Previously the interceptor captured the login-trigger URL itself, so after login
the user was bounced back to /dotCMS/login (not a real page) and got a 404.
sanitizeRedirect() still guards the value against open redirect. OAuth-only; no
SAML code touched.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

AI: Not Safe To Rollback Area : Backend PR changes Java/Maven backend code Area : Frontend PR changes Angular/TypeScript frontend code

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

[EPIC] dotAuth: Migrate Enterprise OAuth/OIDC to Core

1 participant