diff --git a/.mvn/maven-build-cache-config.xml b/.mvn/maven-build-cache-config.xml index f500f6dadf68..516e3999fe5c 100644 --- a/.mvn/maven-build-cache-config.xml +++ b/.mvn/maven-build-cache-config.xml @@ -28,7 +28,7 @@ https://maven.apache.org/extensions/maven-build-cache-extension/maven-build-cach xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/BUILD-CACHE-CONFIG/1.0.0 https://maven.apache.org/xsd/build-cache-config-1.0.0.xsd"> - true + false SHA-256 true diff --git a/bom/application/pom.xml b/bom/application/pom.xml index e2ae5b6b36df..03484f0a7b98 100644 --- a/bom/application/pom.xml +++ b/bom/application/pom.xml @@ -1242,6 +1242,14 @@ 0.9.1 + + + + com.nimbusds + nimbus-jose-jwt + 9.37.4 + + org.bouncycastle bcpkix-jdk18on diff --git a/core-web/apps/dotcms-ui/src/app/app.routes.ts b/core-web/apps/dotcms-ui/src/app/app.routes.ts index beba61a2f00c..856d809d924c 100644 --- a/core-web/apps/dotcms-ui/src/app/app.routes.ts +++ b/core-web/apps/dotcms-ui/src/app/app.routes.ts @@ -163,6 +163,13 @@ const PORTLETS_ANGULAR: Route[] = [ loadChildren: () => import('@dotcms/portlets/dot-es-search/portlet').then((m) => m.dotEsSearchRoutes) }, + { + path: 'dotAuth', + canActivate: [MenuGuardService], + canActivateChild: [MenuGuardService], + data: { reuseRoute: false }, + loadChildren: () => import('@dotcms/portlets/dot-auth/portlet').then((m) => m.dotAuthRoutes) + }, { path: 'tags', canActivate: [MenuGuardService], diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/dot-apps-import-export-dialog/store/dot-apps-import-export-dialog.store.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/dot-apps-import-export-dialog/store/dot-apps-import-export-dialog.store.ts index 8b7c6fe76eab..0d433f634bff 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/dot-apps-import-export-dialog/store/dot-apps-import-export-dialog.store.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/dot-apps-import-export-dialog/store/dot-apps-import-export-dialog.store.ts @@ -42,9 +42,6 @@ const initialState: DotAppsImportExportDialogState = { errorMessage: null }; -// Subject to emit when import succeeds -const importSuccessSubject = new Subject(); - export const DotAppsImportExportDialogStore = signalStore( { providedIn: 'root' }, withState(initialState), @@ -63,12 +60,14 @@ export const DotAppsImportExportDialogStore = signalStore( return ''; }) })), - withProps(() => ({ - /** - * Observable that emits when import succeeds - */ - importSuccess$: importSuccessSubject.asObservable() - })), + withProps(() => { + const importSuccessSubject = new Subject(); + + return { + _importSuccessSubject: importSuccessSubject, + importSuccess$: importSuccessSubject.asObservable() + }; + }), withMethods((store) => { const dotAppsService = inject(DotAppsService); const dotMessageService = inject(DotMessageService); @@ -77,7 +76,7 @@ export const DotAppsImportExportDialogStore = signalStore( /** * Open the export dialog */ - openExport: (app: DotApp, site?: DotAppsSite) => { + openExport: (app: DotApp | null, site?: DotAppsSite) => { patchState(store, { visible: true, action: dialogAction.EXPORT, @@ -185,7 +184,7 @@ export const DotAppsImportExportDialogStore = signalStore( next: (status: string) => { if (status !== '400') { patchState(store, initialState); - importSuccessSubject.next(); + store._importSuccessSubject.next(); } else { patchState(store, { status: ComponentStatus.ERROR, diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/dot-apps-list/dot-apps-list.component.html b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/dot-apps-list/dot-apps-list.component.html index eb03e173d969..8c6b4d7aad59 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/dot-apps-list/dot-apps-list.component.html +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/dot-apps-list/dot-apps-list.component.html @@ -25,7 +25,7 @@ + + @if (showAdvanced()) { +
+
+ + + + {{ 'dotauth.help.callbackUrl' | dm }} + +
+ @if (oidc().discoveryStatus !== 'ok') { +
+ + + @if (error('issuer')) { + {{ error('issuer')! | dm }} + } +
+ } +
+ + +
+
+ } + + + + +
+
+
+

+ {{ 'dotauth.config.oidc.claims.title' | dm }} +

+
{{ 'dotauth.config.oidc.claims.subtitle' | dm }}
+
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
diff --git a/core-web/libs/portlets/dot-auth/src/lib/dot-auth-config/components/dot-auth-oidc-connection.component.ts b/core-web/libs/portlets/dot-auth/src/lib/dot-auth-config/components/dot-auth-oidc-connection.component.ts new file mode 100644 index 000000000000..ce191f2797db --- /dev/null +++ b/core-web/libs/portlets/dot-auth/src/lib/dot-auth-config/components/dot-auth-oidc-connection.component.ts @@ -0,0 +1,51 @@ +import { ChangeDetectionStrategy, Component, input, output, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; + +import { ButtonModule } from 'primeng/button'; +import { InputTextModule } from 'primeng/inputtext'; +import { TooltipModule } from 'primeng/tooltip'; + +import { DOT_AUTH_HIDDEN_SECRET_MASK, DotAuthOidcConfig } from '@dotcms/dotcms-models'; +import { DotMessagePipe } from '@dotcms/ui'; + +export interface OidcConnectionChange { + path: string; + value: unknown; +} + +@Component({ + selector: 'dot-auth-oidc-connection', + standalone: true, + imports: [FormsModule, ButtonModule, InputTextModule, TooltipModule, DotMessagePipe], + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: './dot-auth-oidc-connection.component.html', + styleUrl: '../_dot-auth-shared.scss', + host: { class: 'section-group' } +}) +export class DotAuthOidcConnectionComponent { + readonly oidc = input.required(); + readonly callbackUrl = input(''); + readonly errors = input>({}); + + readonly fieldChange = output(); + readonly discover = output(); + + readonly showAdvanced = signal(false); + + isSecretStored(): boolean { + const secret = this.oidc().clientSecret; + return secret === '****' || secret === DOT_AUTH_HIDDEN_SECRET_MASK; + } + + error(field: string): string | null { + return this.errors()[`oidc.${field}`] ?? null; + } + + onChange(field: string, value: unknown): void { + this.fieldChange.emit({ path: `oidc.${field}`, value }); + } + + onCallbackUrlChange(value: string): void { + this.fieldChange.emit({ path: 'callbackUrl', value }); + } +} diff --git a/core-web/libs/portlets/dot-auth/src/lib/dot-auth-config/components/dot-auth-provisioning.component.html b/core-web/libs/portlets/dot-auth/src/lib/dot-auth-config/components/dot-auth-provisioning.component.html new file mode 100644 index 000000000000..39fd25023505 --- /dev/null +++ b/core-web/libs/portlets/dot-auth/src/lib/dot-auth-config/components/dot-auth-provisioning.component.html @@ -0,0 +1,138 @@ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ + +
+
+ + +
+
+
{{ 'dotauth.field.roleBehavior' | dm }}
+
+ {{ 'dotauth.field.roleBehavior.detail' | dm }} +
+
+
+ @for (opt of roleBehaviorOptions; track opt.value) { + + } +
+
+ + +
+
+
+
{{ 'dotauth.field.groupMappings' | dm }}
+
+ {{ 'dotauth.field.groupMappings.detail' | dm }} +
+
+ +
+ + @if (config().groupMappings.length === 0) { +
{{ 'dotauth.empty.mappings' | dm }}
+ } @else { +
+ + + + + + + + + + @for (mapping of config().groupMappings; track $index; let i = $index) { + + + + + + } + +
{{ 'dotauth.field.idpGroup' | dm }}{{ 'dotauth.field.dotcmsRole' | dm }}
+ + + + + +
+
+ } +
+
diff --git a/core-web/libs/portlets/dot-auth/src/lib/dot-auth-config/components/dot-auth-provisioning.component.ts b/core-web/libs/portlets/dot-auth/src/lib/dot-auth-config/components/dot-auth-provisioning.component.ts new file mode 100644 index 000000000000..517c2ef2caff --- /dev/null +++ b/core-web/libs/portlets/dot-auth/src/lib/dot-auth-config/components/dot-auth-provisioning.component.ts @@ -0,0 +1,115 @@ +import { ChangeDetectionStrategy, Component, input, output } from '@angular/core'; +import { FormsModule } from '@angular/forms'; + +import { ButtonModule } from 'primeng/button'; +import { InputTextModule } from 'primeng/inputtext'; +import { ToggleSwitchModule } from 'primeng/toggleswitch'; +import { TooltipModule } from 'primeng/tooltip'; + +import { + DotAuthGroupMapping, + DotAuthProvisioningConfig, + DotAuthRoleBehavior +} from '@dotcms/dotcms-models'; +import { DotMessagePipe } from '@dotcms/ui'; + +export interface ProvisioningChange { + path: string; + value: unknown; +} + +interface RoleBehaviorOption { + label: string; + value: DotAuthRoleBehavior; + description: string; +} + +@Component({ + selector: 'dot-auth-provisioning', + standalone: true, + host: { + class: 'dot-auth-provisioning' + }, + imports: [ + FormsModule, + ButtonModule, + InputTextModule, + ToggleSwitchModule, + TooltipModule, + DotMessagePipe + ], + changeDetection: ChangeDetectionStrategy.OnPush, + styleUrl: '../_dot-auth-shared.scss', + templateUrl: './dot-auth-provisioning.component.html' +}) +export class DotAuthProvisioningComponent { + readonly config = input.required>(); + readonly syncLabel = input.required(); + readonly syncKey = input('syncOnLogin'); + + readonly fieldChange = output(); + + readonly roleBehaviorOptions: RoleBehaviorOption[] = [ + { + label: 'dotauth.roleBehavior.syncAll', + value: 'sync-all', + description: 'dotauth.roleBehavior.syncAll.description' + }, + { + label: 'dotauth.roleBehavior.idpOnly', + value: 'idp-only', + description: 'dotauth.roleBehavior.idpOnly.description' + }, + { + label: 'dotauth.roleBehavior.staticOnly', + value: 'static-only', + description: 'dotauth.roleBehavior.staticOnly.description' + }, + { + label: 'dotauth.roleBehavior.additive', + value: 'additive', + description: 'dotauth.roleBehavior.additive.description' + }, + { + label: 'dotauth.roleBehavior.none', + value: 'none', + description: 'dotauth.roleBehavior.none.description' + } + ]; + + defaultRolesText(): string { + return (this.config().defaultRoles ?? []).join(', '); + } + + onChange(field: string, value: unknown): void { + this.fieldChange.emit({ path: field, value }); + } + + onDefaultRolesChange(value: string): void { + this.fieldChange.emit({ + path: 'defaultRoles', + value: value + .split(',') + .map((r) => r.trim()) + .filter(Boolean) + }); + } + + onAddMapping(): void { + const current = [...this.config().groupMappings]; + current.push({ idpGroup: '', dotcmsRole: '' }); + this.fieldChange.emit({ path: 'groupMappings', value: current }); + } + + onRemoveMapping(index: number): void { + const current = [...this.config().groupMappings]; + current.splice(index, 1); + this.fieldChange.emit({ path: 'groupMappings', value: current }); + } + + onMappingChange(index: number, field: keyof DotAuthGroupMapping, value: string): void { + const current = this.config().groupMappings.map((m) => ({ ...m })); + current[index][field] = value; + this.fieldChange.emit({ path: 'groupMappings', value: current }); + } +} diff --git a/core-web/libs/portlets/dot-auth/src/lib/dot-auth-config/components/dot-auth-saml-config.component.html b/core-web/libs/portlets/dot-auth/src/lib/dot-auth-config/components/dot-auth-saml-config.component.html new file mode 100644 index 000000000000..1b9d60004492 --- /dev/null +++ b/core-web/libs/portlets/dot-auth/src/lib/dot-auth-config/components/dot-auth-saml-config.component.html @@ -0,0 +1,316 @@ + +
+
+
+

+ {{ 'dotauth.config.saml.sp.title' | dm }} +

+
{{ 'dotauth.config.saml.sp.subtitle' | dm }}
+
+
+
+
+
+ + + @if (error('entityId')) { + {{ error('entityId')! | dm }} + } +
+
+ + +
+
+ +
+ + + {{ 'dotauth.field.signRequests.detail' | dm }} + +
+
+
+
+
+ + +
+
+
+

+ {{ 'dotauth.config.saml.idp.title' | dm }} +

+
{{ 'dotauth.config.saml.idp.subtitle' | dm }}
+
+
+
+
+ +
+ + +
+
+
+ + + {{ 'dotauth.config.saml.metadataManual.title' | dm }} + +
+ {{ 'dotauth.config.saml.metadataManual.hint' | dm }} +
+
+ +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+

+ {{ 'dotauth.config.saml.signing.title' | dm }} +

+
{{ 'dotauth.config.saml.signing.subtitle' | dm }}
+
+
+
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+
+ + + {{ 'dotauth.config.saml.keyOverrides.title' | dm }} + +
+ {{ 'dotauth.config.saml.keyOverrides.hint' | dm }} +
+
+
+ + +
+
+ + +
+
+
+
+
+ + +
+
+
+

+ {{ 'dotauth.config.saml.claims.title' | dm }} +

+
{{ 'dotauth.config.saml.claims.subtitle' | dm }}
+
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+

+ {{ 'dotauth.config.saml.extraProps.title' | dm }} +

+
{{ 'dotauth.config.saml.extraProps.subtitle' | dm }}
+
+ +
+
+ @if ((saml().extraProperties ?? []).length === 0) { +
{{ 'dotauth.empty.extraProps' | dm }}
+ } @else { +
+ + + + + + + + + + @for (prop of saml().extraProperties; track $index; let i = $index) { + + + + + + } + +
+ {{ 'dotauth.field.propertyKey' | dm }} + + {{ 'dotauth.field.propertyValue' | dm }} +
+ + + + + +
+
+ } +
+
diff --git a/core-web/libs/portlets/dot-auth/src/lib/dot-auth-config/components/dot-auth-saml-config.component.ts b/core-web/libs/portlets/dot-auth/src/lib/dot-auth-config/components/dot-auth-saml-config.component.ts new file mode 100644 index 000000000000..ccc1464ffd55 --- /dev/null +++ b/core-web/libs/portlets/dot-auth/src/lib/dot-auth-config/components/dot-auth-saml-config.component.ts @@ -0,0 +1,83 @@ +import { + ChangeDetectionStrategy, + Component, + ElementRef, + input, + output, + viewChild +} from '@angular/core'; +import { FormsModule } from '@angular/forms'; + +import { ButtonModule } from 'primeng/button'; +import { InputTextModule } from 'primeng/inputtext'; +import { TextareaModule } from 'primeng/textarea'; +import { ToggleSwitchModule } from 'primeng/toggleswitch'; +import { TooltipModule } from 'primeng/tooltip'; + +import { DotAuthSamlUiConfig } from '@dotcms/dotcms-models'; +import { DotMessagePipe } from '@dotcms/ui'; + +export interface SamlConfigChange { + path: string; + value: unknown; +} + +@Component({ + selector: 'dot-auth-saml-config', + standalone: true, + imports: [ + FormsModule, + ButtonModule, + InputTextModule, + TextareaModule, + ToggleSwitchModule, + TooltipModule, + DotMessagePipe + ], + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: './dot-auth-saml-config.component.html', + styleUrl: '../_dot-auth-shared.scss', + host: { class: 'section-group' } +}) +export class DotAuthSamlConfigComponent { + readonly saml = input.required(); + readonly errors = input>({}); + + readonly fieldChange = output(); + readonly fetchMetadata = output(); + + readonly metadataDetails = viewChild>('metadataDetails'); + + metadataFetchUrl = ''; + + openMetadataPanel(): void { + const el = this.metadataDetails()?.nativeElement; + if (el) el.open = true; + } + + error(field: string): string | null { + return this.errors()[`saml.${field}`] ?? null; + } + + onChange(field: string, value: unknown): void { + this.fieldChange.emit({ path: `saml.${field}`, value }); + } + + addExtraProperty(): void { + const current = [...(this.saml().extraProperties ?? [])]; + current.push({ key: '', value: '' }); + this.fieldChange.emit({ path: 'saml.extraProperties', value: current }); + } + + removeExtraProperty(index: number): void { + const current = [...(this.saml().extraProperties ?? [])]; + current.splice(index, 1); + this.fieldChange.emit({ path: 'saml.extraProperties', value: current }); + } + + onExtraPropertyChange(index: number, field: 'key' | 'value', value: string): void { + const current = (this.saml().extraProperties ?? []).map((p) => ({ ...p })); + current[index][field] = value; + this.fieldChange.emit({ path: 'saml.extraProperties', value: current }); + } +} diff --git a/core-web/libs/portlets/dot-auth/src/lib/dot-auth-config/components/dot-auth-trusted-idp-editor.component.html b/core-web/libs/portlets/dot-auth/src/lib/dot-auth-config/components/dot-auth-trusted-idp-editor.component.html new file mode 100644 index 000000000000..2ef65b42d02d --- /dev/null +++ b/core-web/libs/portlets/dot-auth/src/lib/dot-auth-config/components/dot-auth-trusted-idp-editor.component.html @@ -0,0 +1,200 @@ +
+
+ +
+
+ {{ idp().name || ('dotauth.idp.unnamed' | dm) }} +
+
+ {{ idp().issuer || ('dotauth.idp.noIssuer' | dm) }} +
+
+ +
+ +
+
+ +
+
+ @if (expanded()) { +
+ +
+
+ {{ 'dotauth.idp.discovery.label' | dm }} + + — {{ 'dotauth.field.optional.lower' | dm }} + +
+
+ +
+ + +
+ @if (idp().discoveryStatus === 'ok') { +
+ + {{ 'dotauth.discovery.idp.success' | dm }} +
+ } + @if (idp().discoveryStatus === 'error') { +
+ + {{ 'dotauth.discovery.idp.error' | dm }} +
+ } +
+
+ + +
+
{{ 'dotauth.idp.connection.label' | dm }}
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
{{ 'dotauth.idp.claims.label' | dm }}
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
{{ 'dotauth.idp.provisioning.label' | dm }}
+ +
+
+ } +
diff --git a/core-web/libs/portlets/dot-auth/src/lib/dot-auth-config/components/dot-auth-trusted-idp-editor.component.ts b/core-web/libs/portlets/dot-auth/src/lib/dot-auth-config/components/dot-auth-trusted-idp-editor.component.ts new file mode 100644 index 000000000000..27efb5805ced --- /dev/null +++ b/core-web/libs/portlets/dot-auth/src/lib/dot-auth-config/components/dot-auth-trusted-idp-editor.component.ts @@ -0,0 +1,71 @@ +import { ChangeDetectionStrategy, Component, input, output } from '@angular/core'; +import { FormsModule } from '@angular/forms'; + +import { ButtonModule } from 'primeng/button'; +import { InputTextModule } from 'primeng/inputtext'; +import { TagModule } from 'primeng/tag'; +import { ToggleSwitchModule } from 'primeng/toggleswitch'; +import { TooltipModule } from 'primeng/tooltip'; + +import { DotAuthTrustedIdp } from '@dotcms/dotcms-models'; +import { DotMessagePipe } from '@dotcms/ui'; + +import { + DotAuthProvisioningComponent, + ProvisioningChange +} from './dot-auth-provisioning.component'; + +export interface TrustedIdpChange { + path: string; + value: unknown; +} + +@Component({ + selector: 'dot-auth-trusted-idp-editor', + standalone: true, + imports: [ + FormsModule, + ButtonModule, + InputTextModule, + TagModule, + ToggleSwitchModule, + TooltipModule, + DotMessagePipe, + DotAuthProvisioningComponent + ], + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: './dot-auth-trusted-idp-editor.component.html', + styleUrl: '../_dot-auth-shared.scss' +}) +export class DotAuthTrustedIdpEditorComponent { + readonly idp = input.required(); + readonly index = input.required(); + readonly expanded = input(false); + + readonly fieldChange = output(); + readonly toggle = output(); + readonly remove = output(); + readonly discover = output(); + + get basePath(): string { + return `headless.trustedIdps.${this.index()}`; + } + + onChange(field: string, value: unknown): void { + this.fieldChange.emit({ path: `${this.basePath}.${field}`, value }); + } + + onProvisioningChange(event: ProvisioningChange): void { + this.fieldChange.emit({ path: `${this.basePath}.${event.path}`, value: event.value }); + } + + onAlgsChange(value: string): void { + this.fieldChange.emit({ + path: `${this.basePath}.algs`, + value: value + .split(',') + .map((a) => a.trim()) + .filter(Boolean) + }); + } +} diff --git a/core-web/libs/portlets/dot-auth/src/lib/dot-auth-config/components/index.ts b/core-web/libs/portlets/dot-auth/src/lib/dot-auth-config/components/index.ts new file mode 100644 index 000000000000..40c8d2aeb2e0 --- /dev/null +++ b/core-web/libs/portlets/dot-auth/src/lib/dot-auth-config/components/index.ts @@ -0,0 +1,10 @@ +export { DotAuthOidcConnectionComponent } from './dot-auth-oidc-connection.component'; +export type { OidcConnectionChange } from './dot-auth-oidc-connection.component'; +export { DotAuthSamlConfigComponent } from './dot-auth-saml-config.component'; +export type { SamlConfigChange } from './dot-auth-saml-config.component'; +export { DotAuthProvisioningComponent } from './dot-auth-provisioning.component'; +export type { ProvisioningChange } from './dot-auth-provisioning.component'; +export { DotAuthTrustedIdpEditorComponent } from './dot-auth-trusted-idp-editor.component'; +export type { TrustedIdpChange } from './dot-auth-trusted-idp-editor.component'; +export { DotAuthHeadlessSectionComponent } from './dot-auth-headless-section.component'; +export type { HeadlessChange } from './dot-auth-headless-section.component'; diff --git a/core-web/libs/portlets/dot-auth/src/lib/dot-auth-config/dot-auth-config.component.html b/core-web/libs/portlets/dot-auth/src/lib/dot-auth-config/dot-auth-config.component.html new file mode 100644 index 000000000000..bfb46ad2dc6c --- /dev/null +++ b/core-web/libs/portlets/dot-auth/src/lib/dot-auth-config/dot-auth-config.component.html @@ -0,0 +1,376 @@ + + + +
+ +
+ @if (!store.isSystem()) { + @if (!isOverriding()) { +
+ + {{ 'dotauth.banner.inheriting' | dm }} +
+ +
+ } @else { +
+ + {{ 'dotauth.banner.overriding' | dm }} +
+ } + } + +
+ +
+
+ +
+
+

+ {{ 'dotauth.config.sso.title' | dm }} +

+

+ {{ 'dotauth.config.sso.subtitle' | dm }} +

+
+
+
+ + {{ 'dotauth.field.ssoEnabled' | dm }} + + +
+
+ + +
+
+
+

+ {{ 'dotauth.config.protocol.title' | dm }} +

+
{{ 'dotauth.config.protocol.subtitle' | dm }}
+
+
+
+
+
+ @if (store.draft().protocol !== 'oidc') { + + {{ 'dotauth.protocol.recommended' | dm }} + + } + @if (store.draft().protocol === 'oidc') { + + + + } +
+ +
+

{{ 'dotauth.protocol.oauth' | dm }}

+

{{ 'dotauth.protocol.oauth.description' | dm }}

+
+
+ @if (store.draft().protocol === 'saml') { + + + + } +
+ +
+

{{ 'dotauth.protocol.saml' | dm }}

+

{{ 'dotauth.protocol.saml.description' | dm }}

+
+
+
+
+ + + @if (store.draft().ssoEnabled) { + + + {{ 'dotauth.status.sso-live' | dm }} + @if (store.isSystem()) { + {{ 'dotauth.status.sso-live.system' | dm }} + } @else { + {{ 'dotauth.status.sso-live.site' | dm }} + } + + + } @else { + + + {{ 'dotauth.status.sso-inactive' | dm }} + {{ 'dotauth.status.sso-inactive.detail' | dm }} + + + } + +
+ + @if (store.draft().protocol !== 'none') { +
+
+
+

+ {{ 'dotauth.config.loginBehavior.title' | dm }} +

+
+ {{ 'dotauth.config.loginBehavior.subtitle' | dm }} +
+
+
+
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+
+
+ } + + @if (store.draft().protocol === 'oidc') { + + + + +
+
+
+

+ {{ 'dotauth.config.provisioning.title' | dm }} +

+
+ {{ 'dotauth.config.provisioning.subtitle' | dm }} +
+
+
+
+ +
+
+ + +
+
+
+

+ {{ 'dotauth.config.session.title' | dm }} +

+
+ {{ 'dotauth.config.session.subtitle' | dm }} +
+
+
+
+
+
+ + +
+
+
+
+ } @else if (store.draft().protocol === 'saml') { + + + + +
+
+
+

+ {{ 'dotauth.config.provisioning.title' | dm }} +

+
+ {{ 'dotauth.config.provisioning.subtitle' | dm }} +
+
+
+
+ +
+
+ } +
+
+
+
+ + +
+
+ @if (store.ssoDirty()) { + + + {{ 'dotauth.config.unsaved' | dm }} + + } @else { + + {{ 'dotauth.config.saved' | dm }} + + } + @if (store.errorCount() > 0) { + + + {{ store.errorCount() }} + {{ + store.errorCount() === 1 + ? ('dotauth.validation.issue' | dm) + : ('dotauth.validation.issues' | dm) + }} + + } +
+
+ + + +
+
+ + + +
+

+ @if (store.isSystem()) { + {{ 'dotauth.confirm.clear.message.system' | dm }} + } @else { + {{ 'dotauth.confirm.clear.message.site' | dm }} + } +

+
+ + +
+
+ + + + +
diff --git a/core-web/libs/portlets/dot-auth/src/lib/dot-auth-config/dot-auth-config.component.scss b/core-web/libs/portlets/dot-auth/src/lib/dot-auth-config/dot-auth-config.component.scss new file mode 100644 index 000000000000..a8cb62a2f32e --- /dev/null +++ b/core-web/libs/portlets/dot-auth/src/lib/dot-auth-config/dot-auth-config.component.scss @@ -0,0 +1,152 @@ +@use "./dot-auth-shared"; + +:host ::ng-deep .p-message { + border-radius: 8px; +} + +// Protocol picker cards +.proto-picker { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 1rem; +} + +.proto-card { + position: relative; + padding: 1.125rem; + border: 1px solid var(--surface-border, #e2e8f0); + border-radius: 12px; + background: #fff; + cursor: pointer; + transition: + border-color 120ms ease, + box-shadow 120ms ease, + background-color 120ms ease; + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.03); + + &:hover { + border-color: var(--primary-300, #93c5fd); + box-shadow: 0 2px 8px -2px rgba(15, 23, 42, 0.08); + } + + &.selected { + border-color: var(--primary-500, #426bf0); + background: linear-gradient( + 135deg, + #fff 0%, + var(--primary-50, #eff6ff) 50%, + var(--primary-100, #dbeafe) 100% + ); + box-shadow: + 0 0 0 3px rgba(66, 107, 240, 0.1), + 0 1px 3px rgba(15, 23, 42, 0.04); + } + + .icon { + width: 38px; + height: 38px; + border-radius: 8px; + display: grid; + place-items: center; + background: rgba(66, 107, 240, 0.08); + color: var(--primary-700, #1d4ed8); + margin-bottom: 0.625rem; + } + + h4 { + margin: 0; + font-size: 0.9375rem; + font-weight: 600; + } + + p { + margin: 0.25rem 0 0; + font-size: 0.8125rem; + color: var(--text-color-secondary); + line-height: 1.45; + } + + .badge { + position: absolute; + top: 0.875rem; + right: 0.875rem; + font-size: 0.6875rem; + font-weight: 600; + color: var(--primary-700, #1d4ed8); + background: rgba(66, 107, 240, 0.08); + padding: 0.125rem 0.5rem; + border-radius: 999px; + } + + .check { + position: absolute; + top: 0.875rem; + right: 0.875rem; + color: var(--primary-600, #2563eb); + } +} + +// Inherit banner +.inherit-banner { + display: flex; + align-items: center; + gap: 0.625rem; + padding: 0.625rem 0.875rem; + background: var(--gray-50, #f8fafc); + border: 1px dashed var(--gray-400, #9ca3af); + border-radius: 6px; + font-size: 0.8125rem; + color: var(--text-color-secondary); +} + +.override-banner { + display: flex; + align-items: center; + gap: 0.625rem; + padding: 0.625rem 0.875rem; + background: var(--blue-50, #eff6ff); + border: 1px solid var(--blue-200, #bfdbfe); + border-radius: 6px; + font-size: 0.8125rem; + color: var(--blue-700, #1d4ed8); +} + +// Locked state for inherited config +.inherited-locked { + opacity: 0.45; + pointer-events: none; + user-select: none; + filter: grayscale(0.3); +} + +// Mono code style +.mono { + font-family: ui-monospace, "SF Mono", Menlo, Monaco, Consolas, monospace; + font-size: 0.875em; + background: var(--surface-ground); + padding: 1px 5px; + border-radius: 4px; + border: 1px solid var(--surface-border); +} + +// SSO draft mode +.sso-draft-mode { + position: relative; + + &::before { + content: attr(data-draft-label); + position: absolute; + top: -10px; + left: 16px; + background: var(--gray-100, #f1f5f9); + color: var(--text-color-secondary); + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + padding: 2px 10px; + border-radius: 999px; + border: 1px solid var(--surface-border); + z-index: 1; + } +} diff --git a/core-web/libs/portlets/dot-auth/src/lib/dot-auth-config/dot-auth-config.component.spec.ts b/core-web/libs/portlets/dot-auth/src/lib/dot-auth-config/dot-auth-config.component.spec.ts new file mode 100644 index 000000000000..05f1b1c16e1f --- /dev/null +++ b/core-web/libs/portlets/dot-auth/src/lib/dot-auth-config/dot-auth-config.component.spec.ts @@ -0,0 +1,162 @@ +import { byText, createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest'; + +import { ActivatedRoute, Router } from '@angular/router'; + +import { ConfirmationService, MessageService } from 'primeng/api'; + +import { DotMessageService } from '@dotcms/data-access'; +import { DOT_AUTH_SYSTEM_HOST, DotAuthConfig } from '@dotcms/dotcms-models'; +import { MockDotMessageService } from '@dotcms/utils-testing'; + +import { DotAuthConfigComponent } from './dot-auth-config.component'; +import { DotAuthConfigStore } from './store/dot-auth-config.store'; + +const DRAFT: DotAuthConfig = { + ssoEnabled: true, + protocol: 'oidc', + enableBackend: true, + enableFrontend: false, + hashUserId: true, + callbackUrl: 'http://localhost:8080', + oidc: { + discoveryUrl: 'https://idp.example/.well-known/openid-configuration', + discoveryStatus: 'idle', + issuer: 'https://idp.example', + authUrl: 'https://idp.example/auth', + tokenUrl: 'https://idp.example/token', + jwksUrl: 'https://idp.example/jwks', + userinfoUrl: 'https://idp.example/userinfo', + logoutUrl: '', + clientId: 'dotcms', + clientSecret: '****', + scopes: 'openid email profile', + responseType: 'code', + pkce: false, + audience: '', + claimEmail: 'email', + claimFirstName: 'given_name', + claimLastName: 'family_name', + claimGroups: 'groups', + autoProvision: true, + syncOnLogin: true, + defaultRoles: ['Frontend Editor'], + roleBehavior: 'sync-all', + groupMappings: [{ idpGroup: 'editors', dotcmsRole: 'Frontend Editor' }], + sessionTtlMinutes: 60, + idleTimeoutMinutes: 30, + postLogoutRedirect: '' + }, + saml: { + metadataUrl: '', + entityId: '', + ssoUrl: '', + sloUrl: '', + x509cert: '', + signRequests: true, + wantAssertionsSigned: true, + wantResponseSigned: false, + claimEmail: 'email', + claimFirstName: 'firstName', + claimLastName: 'lastName', + claimGroups: 'groups', + autoProvision: true, + syncOnLogin: true, + defaultRoles: [], + roleBehavior: 'sync-all', + groupMappings: [], + sessionTtlMinutes: 60 + }, + headless: { + enabled: true, + sessionRefTtlMinutes: 60, + clampToIdpExp: true, + allowedOrigins: ['https://app.example'], + trustedIdps: [ + { + id: 'idp-1', + name: 'Marketing IdP', + enabled: true, + discoveryUrl: '', + discoveryStatus: 'idle', + issuer: 'https://idp.example', + jwksUrl: 'https://idp.example/jwks', + audience: 'dotcms', + algs: ['RS256'], + claimEmail: 'email', + claimFirstName: 'given_name', + claimLastName: 'family_name', + claimGroups: 'groups', + autoProvision: true, + syncOnExchange: true, + defaultRoles: ['Frontend Reader'], + roleBehavior: 'sync-all', + groupMappings: [] + } + ] + } +}; + +describe('DotAuthConfigComponent', () => { + let spectator: Spectator; + + const createComponent = createComponentFactory({ + component: DotAuthConfigComponent, + componentProviders: [ + mockProvider(DotAuthConfigStore, { + load: jest.fn(), + saveSso: jest.fn(), + reset: jest.fn(), + clearOverride: jest.fn(), + update: jest.fn(), + setProtocol: jest.fn(), + runOidcDiscovery: jest.fn(), + revokeAllSessionRefs: jest.fn(), + addAllowedOrigin: jest.fn(), + removeAllowedOrigin: jest.fn(), + addTrustedIdp: jest.fn(), + removeTrustedIdp: jest.fn(), + siteId: jest.fn().mockReturnValue(DOT_AUTH_SYSTEM_HOST), + draft: jest.fn().mockReturnValue(DRAFT), + original: jest.fn().mockReturnValue(DRAFT), + configured: jest.fn().mockReturnValue(true), + inherited: jest.fn().mockReturnValue(false), + status: jest.fn().mockReturnValue('loaded'), + errors: jest.fn().mockReturnValue({}), + errorCount: jest.fn().mockReturnValue(0), + dirty: jest.fn().mockReturnValue(false), + ssoDirty: jest.fn().mockReturnValue(false), + isSystem: jest.fn().mockReturnValue(true) + }) + ], + providers: [ + ConfirmationService, + MessageService, + mockProvider(Router), + { + provide: ActivatedRoute, + useValue: { snapshot: { paramMap: { get: () => DOT_AUTH_SYSTEM_HOST } } } + }, + { + provide: DotMessageService, + useValue: new MockDotMessageService({ + 'dotauth.config.sso.title': 'Single sign-on', + 'dotauth.config.headless.title': 'Headless token exchange', + 'dotauth.config.trusted-idps.title': 'Trusted IdPs' + }) + } + ] + }); + + beforeEach(() => { + spectator = createComponent(); + }); + + it('loads the route host and renders the SSO track by default', () => { + expect(spectator.component.store.load).toHaveBeenCalledWith(DOT_AUTH_SYSTEM_HOST); + expect(spectator.query(byText('Single sign-on'))).toExist(); + }); + + it('renders SSO content without tab selection (tabs removed)', () => { + expect(spectator.query(byText('Single sign-on'))).toExist(); + }); +}); diff --git a/core-web/libs/portlets/dot-auth/src/lib/dot-auth-config/dot-auth-config.component.ts b/core-web/libs/portlets/dot-auth/src/lib/dot-auth-config/dot-auth-config.component.ts new file mode 100644 index 000000000000..2bdfba341615 --- /dev/null +++ b/core-web/libs/portlets/dot-auth/src/lib/dot-auth-config/dot-auth-config.component.ts @@ -0,0 +1,198 @@ +import { + ChangeDetectionStrategy, + Component, + OnInit, + effect, + inject, + signal, + viewChild +} from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; + +import { ConfirmationService, MessageService } from 'primeng/api'; +import { ButtonModule } from 'primeng/button'; +import { ConfirmDialogModule } from 'primeng/confirmdialog'; +import { DialogModule } from 'primeng/dialog'; +import { InputTextModule } from 'primeng/inputtext'; +import { MessageModule } from 'primeng/message'; +import { TagModule } from 'primeng/tag'; +import { ToastModule } from 'primeng/toast'; +import { ToggleSwitchModule } from 'primeng/toggleswitch'; +import { TooltipModule } from 'primeng/tooltip'; + +import { DotMessageService } from '@dotcms/data-access'; +import { DOT_AUTH_SYSTEM_HOST, DotAuthUiProtocol } from '@dotcms/dotcms-models'; +import { DotMessagePipe } from '@dotcms/ui'; + +import { + DotAuthOidcConnectionComponent, + DotAuthProvisioningComponent, + DotAuthSamlConfigComponent, + OidcConnectionChange, + ProvisioningChange, + SamlConfigChange +} from './components'; +import { DotAuthConfigStore } from './store/dot-auth-config.store'; + +@Component({ + selector: 'dot-auth-config', + imports: [ + FormsModule, + ButtonModule, + ConfirmDialogModule, + DialogModule, + InputTextModule, + MessageModule, + TagModule, + ToastModule, + ToggleSwitchModule, + TooltipModule, + DotMessagePipe, + DotAuthOidcConnectionComponent, + DotAuthSamlConfigComponent, + DotAuthProvisioningComponent + ], + templateUrl: './dot-auth-config.component.html', + styleUrl: './dot-auth-config.component.scss', + providers: [DotAuthConfigStore, ConfirmationService, MessageService], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class DotAuthConfigComponent implements OnInit { + readonly SYSTEM_HOST = DOT_AUTH_SYSTEM_HOST; + readonly store = inject(DotAuthConfigStore); + + readonly #route = inject(ActivatedRoute); + readonly #router = inject(Router); + readonly #confirm = inject(ConfirmationService); + readonly #messages = inject(MessageService); + readonly #dotMessageService = inject(DotMessageService); + + readonly samlConfig = viewChild('samlConfig'); + readonly isOverriding = signal(false); + readonly showClearDialog = signal(false); + readonly clearConfirmText = signal(''); + + readonly #pendingSaveToast = signal(false); + readonly #pendingClearToast = signal(false); + + constructor() { + effect(() => { + if (this.store.status() === 'loaded' && !this.store.isSystem()) { + this.isOverriding.set(!this.store.inherited()); + } + }); + + effect(() => { + if (this.store.status() === 'error' && this.#pendingSaveToast()) { + this.#pendingSaveToast.set(false); + return; + } + if (this.store.status() === 'loaded' && this.#pendingSaveToast()) { + this.#pendingSaveToast.set(false); + // The save PUT succeeded, but if the follow-up reload failed (which still + // lands on 'loaded') the user already saw an error dialog — don't pair it + // with a contradictory success toast. + if (!this.store.reloadFailed()) { + this.#messages.add({ + severity: 'success', + summary: this.#dotMessageService.get('dotauth.toast.saved') + }); + } + } + }); + + effect(() => { + if (this.store.status() === 'error' && this.#pendingClearToast()) { + this.#pendingClearToast.set(false); + return; + } + if (this.store.status() === 'loaded' && this.#pendingClearToast()) { + this.#pendingClearToast.set(false); + if (!this.store.reloadFailed()) { + this.#messages.add({ + severity: 'success', + summary: this.#dotMessageService.get('dotauth.toast.config-cleared') + }); + } + } + }); + } + + ngOnInit(): void { + this.store.load(this.#route.snapshot.paramMap.get('hostId') ?? this.SYSTEM_HOST); + } + + switchProtocol(protocol: DotAuthUiProtocol): void { + if (protocol === this.store.draft().protocol) return; + if (!this.store.dirty()) { + this.store.setProtocol(protocol); + return; + } + this.#confirm.confirm({ + header: this.#dotMessageService.get('dotauth.confirm.switch-protocol.header'), + message: this.#dotMessageService.get('dotauth.confirm.switch-protocol.message.ui'), + acceptLabel: this.#dotMessageService.get('Continue'), + rejectLabel: this.#dotMessageService.get('Cancel'), + accept: () => this.store.setProtocol(protocol) + }); + } + + save(): void { + const started = this.store.saveSso(); + if (started) { + this.#pendingSaveToast.set(true); + } + } + + back(): void { + if (!this.store.dirty()) { + this.#goToList(); + return; + } + this.#confirm.confirm({ + header: this.#dotMessageService.get('dotauth.confirm.discard.header'), + message: this.#dotMessageService.get('dotauth.confirm.leave.message'), + acceptLabel: this.#dotMessageService.get('dotauth.action.leave'), + rejectLabel: this.#dotMessageService.get('Cancel'), + accept: () => this.#goToList() + }); + } + + // Navigate relative to the parent (shell) route. The config route path is + // `site/:hostId` (two URL segments); a relative `['../']` from here trips an + // infinite loop in Angular's `..` segment resolution, so target the parent. + #goToList(): void { + void this.#router.navigate(['.'], { relativeTo: this.#route.parent }); + } + + clearConfig(): void { + this.showClearDialog.set(false); + this.clearConfirmText.set(''); + this.#pendingClearToast.set(true); + this.store.clearOverride(); + } + + // --- Child component event handlers --- + + onOidcChange(event: OidcConnectionChange): void { + this.store.update(event.path, event.value); + } + + onSamlChange(event: SamlConfigChange): void { + this.store.update(event.path, event.value); + } + + onFetchSamlMetadata(url: string): void { + this.store.fetchSamlMetadata(url); + this.samlConfig()?.openMetadataPanel(); + } + + onProvisioningChange(prefix: string, event: ProvisioningChange): void { + this.store.update(`${prefix}.${event.path}`, event.value); + } + + onDiscoverOidc(): void { + this.store.runOidcDiscovery('oidc'); + } +} diff --git a/core-web/libs/portlets/dot-auth/src/lib/dot-auth-config/store/dot-auth-config.mappers.spec.ts b/core-web/libs/portlets/dot-auth/src/lib/dot-auth-config/store/dot-auth-config.mappers.spec.ts new file mode 100644 index 000000000000..db34dbf3ac1e --- /dev/null +++ b/core-web/libs/portlets/dot-auth/src/lib/dot-auth-config/store/dot-auth-config.mappers.spec.ts @@ -0,0 +1,659 @@ +import { DotAuthConfig, DotAuthConfigView, DotAuthDiscoveryView } from '@dotcms/dotcms-models'; + +import { + DEFAULT_CONFIG, + applyOidcDiscovery, + applyTrustedDiscovery, + clone, + equal, + fromView, + setPath, + toHeadlessPayload, + toPayload, + toWellKnownUrl, + validate, + validateHeadless +} from './dot-auth-config.mappers'; + +describe('dot-auth-config.mappers', () => { + describe('clone / equal', () => { + it('produces a deep copy', () => { + const original = clone(DEFAULT_CONFIG); + const copy = clone(original); + copy.oidc.clientId = 'changed'; + expect(original.oidc.clientId).toBe(''); + }); + + it('equal returns true for identical structures', () => { + expect(equal({ a: 1 }, { a: 1 })).toBe(true); + }); + + it('equal returns false when values differ', () => { + expect(equal({ a: 1 }, { a: 2 })).toBe(false); + }); + }); + + describe('toWellKnownUrl', () => { + it('appends .well-known path when missing', () => { + expect(toWellKnownUrl('https://idp.example')).toBe( + 'https://idp.example/.well-known/openid-configuration' + ); + }); + + it('strips trailing slashes before appending', () => { + expect(toWellKnownUrl('https://idp.example/')).toBe( + 'https://idp.example/.well-known/openid-configuration' + ); + }); + + it('returns url unchanged when it already ends with the well-known path', () => { + const url = 'https://idp.example/.well-known/openid-configuration'; + expect(toWellKnownUrl(url)).toBe(url); + }); + }); + + describe('setPath', () => { + it('sets a top-level property', () => { + const config = clone(DEFAULT_CONFIG); + const result = setPath(config, 'ssoEnabled', true); + expect(result.ssoEnabled).toBe(true); + expect(config.ssoEnabled).toBe(false); + }); + + it('sets a nested property', () => { + const result = setPath(clone(DEFAULT_CONFIG), 'oidc.clientId', 'my-client'); + expect(result.oidc.clientId).toBe('my-client'); + }); + + it('sets an array element by index', () => { + const config = clone(DEFAULT_CONFIG); + config.headless.allowedOrigins = ['https://a.example', 'https://b.example']; + const result = setPath(config, 'headless.allowedOrigins.1', 'https://c.example'); + expect(result.headless.allowedOrigins[1]).toBe('https://c.example'); + }); + }); + + describe('validate', () => { + it('returns empty when protocol is none', () => { + const config = clone(DEFAULT_CONFIG); + config.protocol = 'none'; + expect(validate(config)).toEqual({}); + }); + + it('returns empty when ssoEnabled is false', () => { + const config = clone(DEFAULT_CONFIG); + config.protocol = 'oidc'; + config.ssoEnabled = false; + expect(validate(config)).toEqual({}); + }); + + it('flags missing OIDC required fields', () => { + const config = clone(DEFAULT_CONFIG); + config.protocol = 'oidc'; + config.ssoEnabled = true; + config.oidc.issuer = ''; + config.oidc.clientId = ''; + config.oidc.clientSecret = 'secret'; + const errors = validate(config); + expect(errors['oidc.issuer']).toBeDefined(); + expect(errors['oidc.clientId']).toBeDefined(); + expect(errors['oidc.clientSecret']).toBeUndefined(); + }); + + it('flags missing SAML required fields', () => { + const config = clone(DEFAULT_CONFIG); + config.protocol = 'saml'; + config.ssoEnabled = true; + config.saml.entityId = ''; + config.saml.metadataUrl = 'https://idp.example/metadata'; + config.saml.x509cert = ''; + const errors = validate(config); + expect(errors['saml.entityId']).toBeDefined(); + expect(errors['saml.metadataUrl']).toBeUndefined(); + expect(errors['saml.x509cert']).toBeUndefined(); + }); + }); + + describe('validateHeadless', () => { + it('returns empty when headless is disabled', () => { + const config = clone(DEFAULT_CONFIG); + config.headless.enabled = false; + expect(validateHeadless(config)).toEqual({}); + }); + + it('flags missing trusted IdPs when enabled', () => { + const config = clone(DEFAULT_CONFIG); + config.headless.enabled = true; + config.headless.trustedIdps = []; + const errors = validateHeadless(config); + expect(errors['headless.trustedIdps']).toBeDefined(); + }); + + it('returns empty when headless is enabled with at least one IdP', () => { + const config = clone(DEFAULT_CONFIG); + config.headless.enabled = true; + config.headless.trustedIdps = [ + { + id: '1', + name: 'Test', + enabled: true, + discoveryUrl: '', + discoveryStatus: 'idle', + issuer: 'https://idp.example', + jwksUrl: 'https://idp.example/jwks', + audience: '', + algs: ['RS256'], + claimEmail: 'email', + claimFirstName: 'given_name', + claimLastName: 'family_name', + claimGroups: 'groups', + autoProvision: true, + syncOnExchange: true, + defaultRoles: [], + roleBehavior: 'sync-all', + groupMappings: [] + } + ]; + expect(validateHeadless(config)).toEqual({}); + }); + }); + + describe('fromView — OIDC', () => { + const OIDC_VIEW: DotAuthConfigView = { + hostId: 'SYSTEM_HOST', + protocol: 'OAUTH', + configured: true, + inherited: false, + values: { + enabled: true, + enableBackend: true, + enableFrontend: false, + hashUserId: true, + callbackUrl: 'https://cms.example/callback', + issuerUrl: 'https://idp.example', + clientId: 'my-client', + clientSecret: '****', + scopes: 'openid email profile groups', + authorizationUrl: 'https://idp.example/auth', + tokenUrl: 'https://idp.example/token', + userinfoUrl: 'https://idp.example/userinfo', + logoutUrl: 'https://idp.example/logout', + groupsClaim: 'roles', + emailClaim: 'email', + firstNameClaim: 'first', + lastNameClaim: 'last', + autoProvision: false, + extraRoles: 'Editor,Reviewer', + buildRolesStrategy: 'staticadd', + groupMappings: JSON.stringify([{ idpGroup: 'admins', dotcmsRole: 'Admin' }]) + }, + headlessValues: {} + }; + + it('maps protocol to oidc', () => { + const config = fromView(OIDC_VIEW); + expect(config.protocol).toBe('oidc'); + expect(config.ssoEnabled).toBe(true); + }); + + it('maps OIDC connection fields', () => { + const config = fromView(OIDC_VIEW); + expect(config.oidc.issuer).toBe('https://idp.example'); + expect(config.oidc.clientId).toBe('my-client'); + expect(config.oidc.clientSecret).toBe('****'); + expect(config.oidc.authUrl).toBe('https://idp.example/auth'); + expect(config.oidc.tokenUrl).toBe('https://idp.example/token'); + }); + + it('maps OIDC claim fields', () => { + const config = fromView(OIDC_VIEW); + expect(config.oidc.claimGroups).toBe('roles'); + expect(config.oidc.claimEmail).toBe('email'); + expect(config.oidc.claimFirstName).toBe('first'); + expect(config.oidc.claimLastName).toBe('last'); + }); + + it('maps OIDC provisioning fields', () => { + const config = fromView(OIDC_VIEW); + expect(config.oidc.autoProvision).toBe(false); + expect(config.oidc.defaultRoles).toEqual(['Editor', 'Reviewer']); + expect(config.oidc.roleBehavior).toBe('additive'); + expect(config.oidc.groupMappings).toEqual([ + { idpGroup: 'admins', dotcmsRole: 'Admin' } + ]); + }); + + it('maps top-level login behavior fields', () => { + const config = fromView(OIDC_VIEW); + expect(config.enableBackend).toBe(true); + expect(config.enableFrontend).toBe(false); + expect(config.callbackUrl).toBe('https://cms.example/callback'); + }); + + it('sets protocol none when not configured and not inherited', () => { + const view: DotAuthConfigView = { + ...OIDC_VIEW, + configured: false, + inherited: false + }; + expect(fromView(view).protocol).toBe('none'); + }); + }); + + describe('fromView — SAML', () => { + const SAML_VIEW: DotAuthConfigView = { + hostId: 'SYSTEM_HOST', + protocol: 'SAML', + configured: true, + inherited: false, + values: { + enable: true, + idpName: 'Okta', + sPIssuerURL: 'https://cms.example', + sPEndpointHostname: '', + signatureValidationType: 'responseandassertion', + idPMetadataFile: 'https://idp.example/metadata.xml', + publicCert: 'MIIC...', + privateKey: '****', + 'identity.provider.destinationsso.url': 'https://idp.example/sso', + 'identity.provider.destinationslo.url': 'https://idp.example/slo', + 'attribute.email.name': 'mail', + 'attribute.firstname.name': 'givenName', + 'attribute.lastname.name': 'sn', + 'attribute.roles.name': 'memberOf', + 'allow.user.synchronization': 'true', + 'login.email.update': 'false', + 'role.extra': 'Reviewer,Author', + 'build.roles': 'idp', + groupMappings: JSON.stringify([]), + enableBackend: 'true', + enableFrontend: 'true', + customSamlProp: 'customValue' + }, + headlessValues: {} + }; + + it('maps protocol to saml', () => { + const config = fromView(SAML_VIEW); + expect(config.protocol).toBe('saml'); + expect(config.ssoEnabled).toBe(true); + }); + + it('maps SAML service provider fields', () => { + const config = fromView(SAML_VIEW); + expect(config.saml.entityId).toBe('https://cms.example'); + expect(config.saml.metadataUrl).toBe('https://idp.example/metadata.xml'); + expect(config.saml.x509cert).toBe('MIIC...'); + expect(config.saml.privateKey).toBe('****'); + }); + + it('maps SAML IdP endpoint fields', () => { + const config = fromView(SAML_VIEW); + expect(config.saml.ssoUrl).toBe('https://idp.example/sso'); + expect(config.saml.sloUrl).toBe('https://idp.example/slo'); + }); + + it('maps signature validation to individual booleans', () => { + const config = fromView(SAML_VIEW); + expect(config.saml.wantAssertionsSigned).toBe(true); + expect(config.saml.wantResponseSigned).toBe(true); + }); + + it('maps SAML claim fields', () => { + const config = fromView(SAML_VIEW); + expect(config.saml.claimEmail).toBe('mail'); + expect(config.saml.claimFirstName).toBe('givenName'); + expect(config.saml.claimLastName).toBe('sn'); + expect(config.saml.claimGroups).toBe('memberOf'); + }); + + it('maps SAML provisioning fields', () => { + const config = fromView(SAML_VIEW); + expect(config.saml.autoProvision).toBe(true); + expect(config.saml.syncOnLogin).toBe(false); + expect(config.saml.defaultRoles).toEqual(['Reviewer', 'Author']); + expect(config.saml.roleBehavior).toBe('idp-only'); + }); + + it('extracts non-declared keys as extraProperties', () => { + const config = fromView(SAML_VIEW); + expect(config.saml.extraProperties).toEqual([ + { key: 'customSamlProp', value: 'customValue' } + ]); + }); + }); + + describe('fromView — headless values', () => { + it('parses headless config from headlessValues', () => { + const view: DotAuthConfigView = { + hostId: 'SYSTEM_HOST', + protocol: 'OAUTH', + configured: true, + inherited: false, + values: { enabled: true, clientId: 'c', clientSecret: 's', issuerUrl: 'i' }, + headlessValues: { + enabled: true, + sessionRefTtlMinutes: '120', + clampToIdpExp: false, + allowedOrigins: JSON.stringify(['https://a.example']), + trustedIdps: JSON.stringify([ + { + id: '1', + name: 'T', + enabled: true, + issuer: 'i', + jwksUrl: 'j', + algs: ['RS256'] + } + ]) + } + }; + const config = fromView(view); + expect(config.headless.enabled).toBe(true); + expect(config.headless.sessionRefTtlMinutes).toBe(120); + expect(config.headless.clampToIdpExp).toBe(false); + expect(config.headless.allowedOrigins).toEqual(['https://a.example']); + expect(config.headless.trustedIdps).toHaveLength(1); + }); + + it('uses defaults when headlessValues is empty', () => { + const view: DotAuthConfigView = { + hostId: 'h', + protocol: 'OAUTH', + configured: false, + inherited: false, + values: {}, + headlessValues: {} + }; + const config = fromView(view); + expect(config.headless.enabled).toBe(false); + expect(config.headless.sessionRefTtlMinutes).toBe(60); + expect(config.headless.clampToIdpExp).toBe(true); + }); + }); + + describe('toPayload — OIDC', () => { + it('produces an OAUTH payload with correct field mapping', () => { + const config = clone(DEFAULT_CONFIG); + config.protocol = 'oidc'; + config.ssoEnabled = true; + config.oidc.issuer = 'https://idp.example'; + config.oidc.clientId = 'client'; + config.oidc.clientSecret = 'secret'; + config.oidc.scopes = 'openid'; + config.oidc.defaultRoles = ['Editor']; + config.oidc.roleBehavior = 'idp-only'; + + const payload = toPayload(config); + expect(payload.protocol).toBe('OAUTH'); + expect(payload.values.issuerUrl).toBe('https://idp.example'); + expect(payload.values.clientId).toBe('client'); + expect(payload.values.clientSecret).toBe('secret'); + expect(payload.values.extraRoles).toBe('Editor'); + expect(payload.values.buildRolesStrategy).toBe('idp'); + }); + + it('round-trips revocationUrl and groupsUrl so a save never deletes the stored secrets', () => { + const config = clone(DEFAULT_CONFIG); + config.protocol = 'oidc'; + config.oidc.revocationUrl = 'https://idp.example/revoke'; + config.oidc.groupsUrl = 'https://idp.example/groups'; + + const payload = toPayload(config); + expect(payload.values.revocationUrl).toBe('https://idp.example/revoke'); + expect(payload.values.groupsUrl).toBe('https://idp.example/groups'); + }); + + it('omits revocationUrl and groupsUrl when unset', () => { + const config = clone(DEFAULT_CONFIG); + config.protocol = 'oidc'; + + const payload = toPayload(config); + expect(payload.values.revocationUrl).toBeUndefined(); + expect(payload.values.groupsUrl).toBeUndefined(); + }); + }); + + describe('toPayload — SAML', () => { + it('produces a SAML payload with correct field mapping', () => { + const config = clone(DEFAULT_CONFIG); + config.protocol = 'saml'; + config.ssoEnabled = true; + config.saml.entityId = 'https://cms.example'; + config.saml.metadataUrl = 'https://idp/meta'; + config.saml.wantAssertionsSigned = true; + config.saml.wantResponseSigned = true; + config.saml.defaultRoles = ['A', 'B']; + config.saml.roleBehavior = 'static-only'; + config.saml.extraProperties = [{ key: 'extra', value: 'val' }]; + + const payload = toPayload(config); + expect(payload.protocol).toBe('SAML'); + + const vals = payload.values as Record; + expect(vals['sPIssuerURL']).toBe('https://cms.example'); + expect(vals['signatureValidationType']).toBe('responseandassertion'); + expect(vals['role.extra']).toBe('A,B'); + expect(vals['build.roles']).toBe('staticonly'); + expect(vals['extra']).toBe('val'); + }); + + it('maps signature validation combinations correctly', () => { + const config = clone(DEFAULT_CONFIG); + config.protocol = 'saml'; + config.ssoEnabled = true; + + config.saml.wantAssertionsSigned = true; + config.saml.wantResponseSigned = false; + expect( + (toPayload(config).values as Record)['signatureValidationType'] + ).toBe('assertion'); + + config.saml.wantAssertionsSigned = false; + config.saml.wantResponseSigned = true; + expect( + (toPayload(config).values as Record)['signatureValidationType'] + ).toBe('response'); + + config.saml.wantAssertionsSigned = false; + config.saml.wantResponseSigned = false; + expect( + (toPayload(config).values as Record)['signatureValidationType'] + ).toBe('none'); + }); + + it('round-trips idpName and sPEndpointHostname so a save never wipes a working SP hostname', () => { + const config = clone(DEFAULT_CONFIG); + config.protocol = 'saml'; + config.saml.idpName = 'Okta'; + config.saml.spEndpointHostname = 'auth.customer.com'; + + const vals = toPayload(config).values as Record; + expect(vals['idpName']).toBe('Okta'); + expect(vals['sPEndpointHostname']).toBe('auth.customer.com'); + }); + + it('omits sPEndpointHostname when unset so the backend host-name fallback stays intact', () => { + const config = clone(DEFAULT_CONFIG); + config.protocol = 'saml'; + config.saml.spEndpointHostname = ''; + + const vals = toPayload(config).values as Record; + expect(vals['sPEndpointHostname']).toBeUndefined(); + }); + + it('serializes signRequests so the toggle persists', () => { + const config = clone(DEFAULT_CONFIG); + config.protocol = 'saml'; + config.saml.signRequests = false; + + const vals = toPayload(config).values as Record; + expect(vals['signRequests']).toBe('false'); + }); + }); + + describe('fromView/toPayload SAML round-trip', () => { + it('preserves loaded idpName and sPEndpointHostname across an unrelated edit', () => { + const view: DotAuthConfigView = { + hostId: 'SYSTEM_HOST', + protocol: 'SAML', + configured: true, + inherited: false, + values: { + enable: true, + idpName: 'Corp IdP', + sPEndpointHostname: 'auth.customer.com', + sPIssuerURL: 'https://cms.example', + signRequests: 'false' + }, + headlessValues: {} + }; + const config = fromView(view); + const vals = toPayload(config).values as Record; + expect(vals['idpName']).toBe('Corp IdP'); + expect(vals['sPEndpointHostname']).toBe('auth.customer.com'); + expect(vals['signRequests']).toBe('false'); + // and signRequests must not leak into extraProperties as a custom attribute + expect(config.saml.extraProperties).toEqual([]); + }); + }); + + describe('toHeadlessPayload', () => { + it('serializes headless config with JSON-stringified arrays', () => { + const config = clone(DEFAULT_CONFIG); + config.headless.enabled = true; + config.headless.sessionRefTtlMinutes = 120; + config.headless.allowedOrigins = ['https://a.example']; + config.headless.trustedIdps = []; + + const payload = toHeadlessPayload(config); + expect(payload.enabled).toBe(true); + expect(payload.sessionRefTtlMinutes).toBe('120'); + expect(payload.allowedOrigins).toBe('["https://a.example"]'); + expect(payload.trustedIdps).toBe('[]'); + }); + }); + + describe('applyOidcDiscovery', () => { + it('populates OIDC fields from discovery response', () => { + const config = clone(DEFAULT_CONFIG); + config.protocol = 'oidc'; + const discovery: DotAuthDiscoveryView = { + issuer: 'https://discovered.example', + authorizationEndpoint: 'https://discovered.example/auth', + tokenEndpoint: 'https://discovered.example/token', + jwksUri: 'https://discovered.example/jwks', + userinfoEndpoint: 'https://discovered.example/userinfo', + endSessionEndpoint: 'https://discovered.example/logout' + }; + const result = applyOidcDiscovery(config, discovery); + expect(result.oidc.discoveryStatus).toBe('ok'); + expect(result.oidc.issuer).toBe('https://discovered.example'); + expect(result.oidc.authUrl).toBe('https://discovered.example/auth'); + expect(result.oidc.tokenUrl).toBe('https://discovered.example/token'); + expect(result.oidc.jwksUrl).toBe('https://discovered.example/jwks'); + expect(result.oidc.userinfoUrl).toBe('https://discovered.example/userinfo'); + expect(result.oidc.logoutUrl).toBe('https://discovered.example/logout'); + expect(config.oidc.issuer).toBe(''); + }); + + it('preserves existing values for missing discovery fields', () => { + const config = clone(DEFAULT_CONFIG); + config.oidc.issuer = 'https://original.example'; + const result = applyOidcDiscovery(config, {}); + expect(result.oidc.issuer).toBe('https://original.example'); + }); + }); + + describe('applyTrustedDiscovery', () => { + it('populates a trusted IdP from discovery', () => { + const config = clone(DEFAULT_CONFIG); + config.headless.trustedIdps = [ + { + id: '1', + name: 'Test', + enabled: true, + discoveryUrl: '', + discoveryStatus: 'loading', + issuer: '', + jwksUrl: '', + audience: '', + algs: [], + claimEmail: 'email', + claimFirstName: 'given_name', + claimLastName: 'family_name', + claimGroups: 'groups', + autoProvision: true, + syncOnExchange: true, + defaultRoles: [], + roleBehavior: 'sync-all', + groupMappings: [] + } + ]; + const discovery: DotAuthDiscoveryView = { + issuer: 'https://trusted.example', + jwksUri: 'https://trusted.example/jwks', + signingAlgs: ['RS256', 'ES256'] + }; + const result = applyTrustedDiscovery(config, 'headless.trustedIdps.0', discovery); + expect(result.headless.trustedIdps[0].discoveryStatus).toBe('ok'); + expect(result.headless.trustedIdps[0].issuer).toBe('https://trusted.example'); + expect(result.headless.trustedIdps[0].jwksUrl).toBe('https://trusted.example/jwks'); + expect(result.headless.trustedIdps[0].algs).toEqual(['RS256', 'ES256']); + }); + }); + + describe('roleBehavior round-trip', () => { + const BEHAVIORS: Array<[string, DotAuthConfig['oidc']['roleBehavior']]> = [ + ['all', 'sync-all'], + ['idp', 'idp-only'], + ['staticadd', 'additive'], + ['staticonly', 'static-only'], + ['none', 'none'] + ]; + + it.each(BEHAVIORS)( + 'OIDC: backend "%s" round-trips through UI "%s"', + (backendValue, uiValue) => { + const view: DotAuthConfigView = { + hostId: 'h', + protocol: 'OAUTH', + configured: true, + inherited: false, + values: { buildRolesStrategy: backendValue }, + headlessValues: {} + }; + const config = fromView(view); + expect(config.oidc.roleBehavior).toBe(uiValue); + + config.protocol = 'oidc'; + config.ssoEnabled = true; + const payload = toPayload(config); + expect(payload.values.buildRolesStrategy).toBe(backendValue); + } + ); + + it.each(BEHAVIORS)( + 'SAML: backend "%s" round-trips through UI "%s"', + (backendValue, uiValue) => { + const view: DotAuthConfigView = { + hostId: 'h', + protocol: 'SAML', + configured: true, + inherited: false, + values: { 'build.roles': backendValue }, + headlessValues: {} + }; + const config = fromView(view); + expect(config.saml.roleBehavior).toBe(uiValue); + + config.protocol = 'saml'; + config.ssoEnabled = true; + const payload = toPayload(config); + expect((payload.values as Record)['build.roles']).toBe( + backendValue + ); + } + ); + }); +}); diff --git a/core-web/libs/portlets/dot-auth/src/lib/dot-auth-config/store/dot-auth-config.mappers.ts b/core-web/libs/portlets/dot-auth/src/lib/dot-auth-config/store/dot-auth-config.mappers.ts new file mode 100644 index 000000000000..992899149844 --- /dev/null +++ b/core-web/libs/portlets/dot-auth/src/lib/dot-auth-config/store/dot-auth-config.mappers.ts @@ -0,0 +1,445 @@ +import { + DotAuthConfig, + DotAuthConfigPayload, + DotAuthConfigView, + DotAuthDiscoveryView, + DotAuthHeadlessPayload, + DotAuthRoleBehavior +} from '@dotcms/dotcms-models'; + +export const DEFAULT_CONFIG: DotAuthConfig = { + ssoEnabled: false, + protocol: 'none', + enableBackend: true, + enableFrontend: false, + hashUserId: true, + callbackUrl: '', + oidc: { + discoveryUrl: '', + discoveryStatus: 'idle', + issuer: '', + authUrl: '', + tokenUrl: '', + jwksUrl: '', + userinfoUrl: '', + logoutUrl: '', + revocationUrl: '', + groupsUrl: '', + clientId: '', + clientSecret: '', + scopes: 'openid email profile', + responseType: 'code', + pkce: false, + audience: '', + claimEmail: 'email', + claimFirstName: 'given_name', + claimLastName: 'family_name', + claimGroups: 'groups', + autoProvision: true, + syncOnLogin: true, + defaultRoles: [], + roleBehavior: 'sync-all', + groupMappings: [], + sessionTtlMinutes: 60, + idleTimeoutMinutes: 30, + postLogoutRedirect: '' + }, + saml: { + idpName: 'SAML Identity Provider', + spEndpointHostname: '', + metadataUrl: '', + entityId: '', + ssoUrl: '', + sloUrl: '', + x509cert: '', + privateKey: '', + signRequests: true, + wantAssertionsSigned: true, + wantResponseSigned: false, + claimEmail: 'mail', + claimFirstName: 'givenName', + claimLastName: 'sn', + claimGroups: 'authorisations', + autoProvision: true, + syncOnLogin: true, + defaultRoles: [], + roleBehavior: 'sync-all', + groupMappings: [], + sessionTtlMinutes: 60, + extraProperties: [] + }, + headless: { + enabled: false, + sessionRefTtlMinutes: 60, + clampToIdpExp: true, + trustedIdps: [], + allowedOrigins: [] + } +}; + +export function fromView(view: DotAuthConfigView): DotAuthConfig { + const config = clone(DEFAULT_CONFIG); + config.protocol = + view.configured || view.inherited ? (view.protocol === 'SAML' ? 'saml' : 'oidc') : 'none'; + config.ssoEnabled = + config.protocol !== 'none' && + (view.protocol === 'SAML' + ? (view.values as { enable?: boolean }).enable !== false + : (view.values as { enabled?: boolean }).enabled !== false); + + const vals = view.values as Record; + config.enableBackend = booleanValue(vals['enableBackend'], config.enableBackend); + config.enableFrontend = booleanValue(vals['enableFrontend'], config.enableFrontend); + config.hashUserId = booleanValue(vals['hashUserId'], config.hashUserId); + config.callbackUrl = String(vals['callbackUrl'] ?? config.callbackUrl); + + if (view.protocol === 'SAML') { + const values = view.values; + config.saml = { + ...config.saml, + idpName: String(values.idpName ?? config.saml.idpName), + spEndpointHostname: String(values.sPEndpointHostname ?? ''), + metadataUrl: String(values.idPMetadataFile ?? ''), + entityId: String(values.sPIssuerURL ?? ''), + ssoUrl: String(values['identity.provider.destinationsso.url'] ?? ''), + sloUrl: String(values['identity.provider.destinationslo.url'] ?? ''), + x509cert: String(values.publicCert ?? ''), + privateKey: String(values.privateKey ?? ''), + signRequests: String(values['signRequests'] ?? 'true') === 'true', + wantAssertionsSigned: + values.signatureValidationType === 'assertion' || + values.signatureValidationType === 'responseandassertion', + wantResponseSigned: + values.signatureValidationType === 'response' || + values.signatureValidationType === 'responseandassertion', + claimEmail: String(values['attribute.email.name'] ?? config.saml.claimEmail), + claimFirstName: String( + values['attribute.firstname.name'] ?? config.saml.claimFirstName + ), + claimLastName: String(values['attribute.lastname.name'] ?? config.saml.claimLastName), + claimGroups: String(values['attribute.roles.name'] ?? config.saml.claimGroups), + autoProvision: String(values['allow.user.synchronization'] ?? 'true') === 'true', + syncOnLogin: String(values['login.email.update'] ?? 'true') === 'true', + defaultRoles: splitList(values['role.extra']), + roleBehavior: fromBuildRolesStrategy(values['build.roles'] as string), + groupMappings: parseJson(values['groupMappings'], []), + extraProperties: extractSamlExtraProperties(values) + }; + config.headless = fromHeadlessValues(view, config.headless); + return config; + } + + const values = view.values; + config.oidc = { + ...config.oidc, + issuer: String(values.issuerUrl ?? ''), + authUrl: String(values.authorizationUrl ?? ''), + tokenUrl: String(values.tokenUrl ?? ''), + userinfoUrl: String(values.userinfoUrl ?? ''), + logoutUrl: String(values.logoutUrl ?? ''), + revocationUrl: String(values.revocationUrl ?? ''), + groupsUrl: String(values.groupsUrl ?? ''), + clientId: String(values.clientId ?? ''), + clientSecret: String(values.clientSecret ?? ''), + scopes: String(values.scopes ?? config.oidc.scopes), + claimGroups: String(values.groupsClaim ?? config.oidc.claimGroups), + claimEmail: String(values.emailClaim ?? config.oidc.claimEmail), + claimFirstName: String(values.firstNameClaim ?? config.oidc.claimFirstName), + claimLastName: String(values.lastNameClaim ?? config.oidc.claimLastName), + autoProvision: booleanValue(values.autoProvision, config.oidc.autoProvision), + syncOnLogin: true, + defaultRoles: splitList(values.extraRoles), + roleBehavior: fromBuildRolesStrategy(values.buildRolesStrategy), + groupMappings: parseJson(values['groupMappings'], config.oidc.groupMappings) + }; + config.headless = fromHeadlessValues(view, config.headless); + return config; +} + +export function toPayload(config: DotAuthConfig): DotAuthConfigPayload { + if (config.protocol === 'saml') { + return { + protocol: 'SAML', + values: { + enable: config.ssoEnabled, + idpName: config.saml.idpName || 'SAML Identity Provider', + sPIssuerURL: config.saml.entityId, + // Omit when unset: the backend replaces the whole secrets row on save, so an + // empty string would be STORED and shadow the host-name fallback downstream. + sPEndpointHostname: config.saml.spEndpointHostname || undefined, + signRequests: String(config.saml.signRequests), + signatureValidationType: config.saml.wantResponseSigned + ? config.saml.wantAssertionsSigned + ? 'responseandassertion' + : 'response' + : config.saml.wantAssertionsSigned + ? 'assertion' + : 'none', + idPMetadataFile: config.saml.metadataUrl, + publicCert: config.saml.x509cert || undefined, + privateKey: config.saml.privateKey || undefined, + buttonParam: '/api/v1/dotsaml/metadata/$siteId', + 'identity.provider.destinationsso.url': config.saml.ssoUrl || undefined, + 'identity.provider.destinationslo.url': config.saml.sloUrl || undefined, + 'attribute.email.name': config.saml.claimEmail || undefined, + 'attribute.firstname.name': config.saml.claimFirstName || undefined, + 'attribute.lastname.name': config.saml.claimLastName || undefined, + 'attribute.roles.name': config.saml.claimGroups || undefined, + 'allow.user.synchronization': String(config.saml.autoProvision), + 'login.email.update': String(config.saml.syncOnLogin), + 'role.extra': config.saml.defaultRoles.join(',') || undefined, + 'build.roles': toBuildRoles(config.saml.roleBehavior), + groupMappings: JSON.stringify(config.saml.groupMappings), + enableBackend: String(config.enableBackend), + enableFrontend: String(config.enableFrontend), + ...Object.fromEntries( + (config.saml.extraProperties ?? []) + .filter((p) => p.key && p.value) + .map((p) => [p.key, p.value]) + ) + } + }; + } + + return { + protocol: 'OAUTH', + values: { + enabled: config.ssoEnabled, + enableBackend: config.enableBackend, + enableFrontend: config.enableFrontend, + hashUserId: config.hashUserId, + callbackUrl: config.callbackUrl || undefined, + providerType: 'OIDC', + issuerUrl: config.oidc.issuer, + clientId: config.oidc.clientId, + clientSecret: config.oidc.clientSecret, + scopes: config.oidc.scopes, + authorizationUrl: config.oidc.authUrl, + tokenUrl: config.oidc.tokenUrl, + userinfoUrl: config.oidc.userinfoUrl, + logoutUrl: config.oidc.logoutUrl, + revocationUrl: config.oidc.revocationUrl || undefined, + groupsUrl: config.oidc.groupsUrl || undefined, + groupsClaim: config.oidc.claimGroups, + emailClaim: config.oidc.claimEmail || undefined, + firstNameClaim: config.oidc.claimFirstName || undefined, + lastNameClaim: config.oidc.claimLastName || undefined, + autoProvision: config.oidc.autoProvision, + groupMappings: JSON.stringify(config.oidc.groupMappings), + extraRoles: config.oidc.defaultRoles.join(','), + buildRolesStrategy: toBuildRoles(config.oidc.roleBehavior) + } + }; +} + +export function validate(config: DotAuthConfig): Record { + if (!config.ssoEnabled || config.protocol === 'none') return {}; + if (config.protocol === 'oidc') { + return required({ + 'oidc.issuer': config.oidc.issuer, + 'oidc.clientId': config.oidc.clientId, + 'oidc.clientSecret': config.oidc.clientSecret + }); + } + return required({ + 'saml.entityId': config.saml.entityId + }); +} + +export function validateHeadless(config: DotAuthConfig): Record { + if (!config.headless.enabled) return {}; + const errors: Record = {}; + if (config.headless.trustedIdps.length === 0) { + errors['headless.trustedIdps'] = 'dotauth.validation.headless.no.trusted.idps'; + } + return errors; +} + +export function toHeadlessPayload(config: DotAuthConfig): DotAuthHeadlessPayload { + return { + enabled: config.headless.enabled, + sessionRefTtlMinutes: String(config.headless.sessionRefTtlMinutes), + clampToIdpExp: config.headless.clampToIdpExp, + allowedOrigins: JSON.stringify(config.headless.allowedOrigins), + trustedIdps: JSON.stringify(config.headless.trustedIdps), + hashUserId: true, + providerType: 'OIDC' + }; +} + +function fromHeadlessValues( + view: DotAuthConfigView, + defaults: DotAuthConfig['headless'] +): DotAuthConfig['headless'] { + const hv = view.headlessValues ?? {}; + return { + ...defaults, + enabled: Boolean(hv.enabled ?? false), + sessionRefTtlMinutes: numberValue(hv.sessionRefTtlMinutes, 60), + clampToIdpExp: booleanValue(hv.clampToIdpExp, true), + allowedOrigins: parseJson(hv.allowedOrigins, []), + trustedIdps: parseJson(hv.trustedIdps, []) + }; +} + +export function applyOidcDiscovery( + config: DotAuthConfig, + view: DotAuthDiscoveryView +): DotAuthConfig { + const draft = clone(config); + draft.oidc.discoveryStatus = 'ok'; + draft.oidc.issuer = view.issuer ?? draft.oidc.issuer; + draft.oidc.authUrl = view.authorizationEndpoint ?? draft.oidc.authUrl; + draft.oidc.tokenUrl = view.tokenEndpoint ?? draft.oidc.tokenUrl; + draft.oidc.jwksUrl = view.jwksUri ?? draft.oidc.jwksUrl; + draft.oidc.userinfoUrl = view.userinfoEndpoint ?? draft.oidc.userinfoUrl; + draft.oidc.logoutUrl = view.endSessionEndpoint ?? draft.oidc.logoutUrl; + return draft; +} + +export function applyTrustedDiscovery( + config: DotAuthConfig, + path: `headless.trustedIdps.${number}`, + view: DotAuthDiscoveryView +): DotAuthConfig { + const draft = clone(config); + const parts = path.split('.'); + const index = Number(parts[parts.length - 1]); + const idp = draft.headless.trustedIdps[index]; + idp.discoveryStatus = 'ok'; + idp.issuer = view.issuer ?? idp.issuer; + idp.jwksUrl = view.jwksUri ?? idp.jwksUrl; + idp.algs = view.signingAlgs?.length ? view.signingAlgs : idp.algs; + return draft; +} + +export function setPath(config: DotAuthConfig, path: string, value: unknown): DotAuthConfig { + const draft = clone(config); + const parts = path.split('.'); + let cursor: Record | unknown[] = draft as unknown as Record; + for (const part of parts.slice(0, -1)) { + cursor = Array.isArray(cursor) + ? (cursor[Number(part)] as Record) + : (cursor[part] as Record); + } + const last = parts[parts.length - 1]; + if (Array.isArray(cursor)) { + cursor[Number(last)] = value; + } else { + cursor[last] = value; + } + return draft; +} + +export function clone(value: T): T { + return structuredClone(value); +} + +export function equal(a: unknown, b: unknown): boolean { + return JSON.stringify(a) === JSON.stringify(b); +} + +export function toWellKnownUrl(url: string): string { + const trimmed = url.replace(/\/+$/, ''); + if (trimmed.endsWith('.well-known/openid-configuration')) return trimmed; + return `${trimmed}/.well-known/openid-configuration`; +} + +function required(values: Record): Record { + return Object.fromEntries( + Object.entries(values) + .filter(([, value]) => !String(value ?? '').trim()) + .map(([key]) => [key, 'dotauth.validation.required']) + ); +} + +function splitList(value: unknown): string[] { + return String(value ?? '') + .split(',') + .map((item) => item.trim()) + .filter(Boolean); +} + +function parseJson(value: unknown, fallback: T): T { + if (!value) return fallback; + try { + return JSON.parse(String(value)) as T; + } catch { + return fallback; + } +} + +function numberValue(value: unknown, fallback: number): number { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function booleanValue(value: unknown, fallback: boolean): boolean { + if (value === undefined || value === null || value === '') return fallback; + return value === true || String(value) === 'true'; +} + +function fromBuildRolesStrategy(value: unknown): DotAuthRoleBehavior { + switch (String(value ?? '').toUpperCase()) { + case 'IDP': + return 'idp-only'; + case 'STATICADD': + return 'additive'; + case 'STATICONLY': + return 'static-only'; + case 'NONE': + return 'none'; + default: + return 'sync-all'; + } +} + +const SAML_ELEVATED_KEYS = new Set([ + 'enable', + 'idpName', + 'sPIssuerURL', + 'sPEndpointHostname', + 'signRequests', + 'signatureValidationType', + 'idPMetadataFile', + 'publicCert', + 'privateKey', + 'buttonParam', + 'identity.provider.destinationsso.url', + 'identity.provider.destinationslo.url', + 'attribute.email.name', + 'attribute.firstname.name', + 'attribute.lastname.name', + 'attribute.roles.name', + 'allow.user.synchronization', + 'login.email.update', + 'role.extra', + 'build.roles', + 'groupMappings', + 'enableBackend', + 'enableFrontend' +]); + +function extractSamlExtraProperties( + values: Record +): { key: string; value: string }[] { + return Object.entries(values) + .filter(([key]) => !SAML_ELEVATED_KEYS.has(key)) + .filter(([, value]) => value !== undefined && value !== null && value !== '') + .map(([key, value]) => ({ key, value: String(value) })); +} + +function toBuildRoles(value: string): string { + switch (value) { + case 'idp-only': + return 'idp'; + case 'additive': + return 'staticadd'; + case 'static-only': + return 'staticonly'; + case 'none': + return 'none'; + default: + return 'all'; + } +} diff --git a/core-web/libs/portlets/dot-auth/src/lib/dot-auth-config/store/dot-auth-config.store.ts b/core-web/libs/portlets/dot-auth/src/lib/dot-auth-config/store/dot-auth-config.store.ts new file mode 100644 index 000000000000..8725f0f8c0a0 --- /dev/null +++ b/core-web/libs/portlets/dot-auth/src/lib/dot-auth-config/store/dot-auth-config.store.ts @@ -0,0 +1,285 @@ +import { patchState, signalStore, withComputed, withMethods, withState } from '@ngrx/signals'; +import { EMPTY } from 'rxjs'; + +import { computed, inject } from '@angular/core'; + +import { catchError, take } from 'rxjs/operators'; + +import { DotAuthService, DotHttpErrorManagerService } from '@dotcms/data-access'; +import { DOT_AUTH_SYSTEM_HOST, DotAuthConfig, DotAuthUiProtocol } from '@dotcms/dotcms-models'; + +import { + DEFAULT_CONFIG, + applyOidcDiscovery, + applyTrustedDiscovery, + clone, + equal, + fromView, + setPath, + toHeadlessPayload, + toPayload, + toWellKnownUrl, + validate, + validateHeadless +} from './dot-auth-config.mappers'; + +type DotAuthConfigStatus = 'init' | 'loading' | 'loaded' | 'saving' | 'error'; + +interface DotAuthConfigState { + siteId: string; + original: DotAuthConfig; + draft: DotAuthConfig; + configured: boolean; + inherited: boolean; + status: DotAuthConfigStatus; + errors: Record; + // True when the most recent load()/reload failed. Lets a component distinguish a + // genuinely-successful save+reload from a save that succeeded but whose follow-up + // reload errored (which, per convention, still lands on status 'loaded'), so a failed + // reload does not masquerade as a successful save with a green toast. + reloadFailed: boolean; +} + +const initialState: DotAuthConfigState = { + siteId: DOT_AUTH_SYSTEM_HOST, + original: clone(DEFAULT_CONFIG), + draft: clone(DEFAULT_CONFIG), + configured: false, + inherited: false, + status: 'init', + errors: {}, + reloadFailed: false +}; + +export const DotAuthConfigStore = signalStore( + withState(initialState), + withComputed((store) => ({ + isSystem: computed(() => store.siteId() === DOT_AUTH_SYSTEM_HOST), + ssoDirty: computed(() => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { headless: _o, ...origSso } = store.original(); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { headless: _d, ...draftSso } = store.draft(); + return !equal(origSso, draftSso); + }), + headlessDirty: computed(() => !equal(store.original().headless, store.draft().headless)), + dirty: computed(() => !equal(store.original(), store.draft())), + errorCount: computed(() => Object.keys(store.errors()).length) + })), + withMethods((store) => { + const service = inject(DotAuthService); + const httpErrorManager = inject(DotHttpErrorManagerService); + + function load(siteId: string): void { + patchState(store, { siteId, status: 'loading', errors: {}, reloadFailed: false }); + service + .getConfig(siteId) + .pipe( + take(1), + catchError((error) => { + httpErrorManager.handle(error); + patchState(store, { status: 'loaded', reloadFailed: true }); + return EMPTY; + }) + ) + .subscribe((view) => { + const config = fromView(view); + patchState(store, { + original: config, + draft: clone(config), + configured: view.configured, + inherited: view.inherited, + status: 'loaded', + reloadFailed: false + }); + }); + } + + function saveSso(): boolean { + const draft = store.draft(); + if (draft.protocol === 'none') { + return false; + } + const validation = validate(draft); + if (Object.keys(validation).length) { + patchState(store, { errors: validation }); + return false; + } + patchState(store, { status: 'saving', errors: {} }); + service + .saveConfig(store.siteId(), toPayload(draft)) + .pipe( + take(1), + catchError((error) => { + httpErrorManager.handle(error); + patchState(store, { status: 'error' }); + return EMPTY; + }) + ) + .subscribe(() => load(store.siteId())); + return true; + } + + function saveHeadless(): boolean { + const validation = validateHeadless(store.draft()); + if (Object.keys(validation).length) { + patchState(store, { errors: validation }); + return false; + } + patchState(store, { status: 'saving', errors: {} }); + service + .saveHeadlessConfig(toHeadlessPayload(store.draft())) + .pipe( + take(1), + catchError((error) => { + httpErrorManager.handle(error); + patchState(store, { status: 'error' }); + return EMPTY; + }) + ) + .subscribe(() => load(store.siteId())); + return true; + } + + return { + load, + saveSso, + saveHeadless, + + reset(): void { + patchState(store, { draft: clone(store.original()), errors: {} }); + }, + + clearOverride(): void { + patchState(store, { status: 'saving' }); + service + .clearConfig(store.siteId()) + .pipe( + take(1), + catchError((error) => { + httpErrorManager.handle(error); + patchState(store, { status: 'loaded' }); + return EMPTY; + }) + ) + .subscribe(() => load(store.siteId())); + }, + + setProtocol(protocol: DotAuthUiProtocol): void { + patchState(store, { + draft: { ...store.draft(), protocol, ssoEnabled: protocol !== 'none' } + }); + }, + + update(path: string, value: unknown): void { + patchState(store, { draft: setPath(store.draft(), path, value) }); + }, + + addAllowedOrigin(): void { + const draft = clone(store.draft()); + draft.headless.allowedOrigins.push(''); + patchState(store, { draft }); + }, + + removeAllowedOrigin(index: number): void { + const draft = clone(store.draft()); + draft.headless.allowedOrigins.splice(index, 1); + patchState(store, { draft }); + }, + + addTrustedIdp(): void { + const draft = clone(store.draft()); + draft.headless.trustedIdps.push({ + id: crypto.randomUUID?.() ?? String(Date.now()), + name: '', + enabled: true, + discoveryUrl: '', + discoveryStatus: 'idle', + issuer: '', + jwksUrl: '', + audience: '', + algs: ['RS256'], + claimEmail: 'email', + claimFirstName: 'given_name', + claimLastName: 'family_name', + claimGroups: 'groups', + autoProvision: true, + syncOnExchange: true, + defaultRoles: [], + roleBehavior: 'sync-all', + groupMappings: [] + }); + patchState(store, { draft }); + }, + + removeTrustedIdp(index: number): void { + const draft = clone(store.draft()); + draft.headless.trustedIdps.splice(index, 1); + patchState(store, { draft }); + }, + + runOidcDiscovery(path: 'oidc' | `headless.trustedIdps.${number}`): void { + const draft = clone(store.draft()); + const rawUrl = + path === 'oidc' + ? draft.oidc.discoveryUrl + : draft.headless.trustedIdps[Number(path.split('.').pop())].discoveryUrl; + if (!rawUrl) return; + const discoveryUrl = toWellKnownUrl(rawUrl); + patchState(store, { + draft: setPath(draft, `${path}.discoveryStatus`, 'loading') + }); + service + .discoverOidc(discoveryUrl) + .pipe( + take(1), + catchError((error) => { + httpErrorManager.handle(error); + patchState(store, { + draft: setPath(store.draft(), `${path}.discoveryStatus`, 'error') + }); + return EMPTY; + }) + ) + .subscribe((view) => { + patchState(store, { + draft: + path === 'oidc' + ? applyOidcDiscovery(store.draft(), view) + : applyTrustedDiscovery(store.draft(), path, view) + }); + }); + }, + + fetchSamlMetadata(url: string): void { + service + .fetchSamlMetadata(url) + .pipe( + take(1), + catchError((error) => { + httpErrorManager.handle(error); + return EMPTY; + }) + ) + .subscribe((xml) => { + patchState(store, { + draft: setPath(store.draft(), 'saml.metadataUrl', xml) + }); + }); + }, + + revokeAllSessionRefs(): void { + service + .revokeAllSessionRefs() + .pipe( + take(1), + catchError((error) => { + httpErrorManager.handle(error); + return EMPTY; + }) + ) + .subscribe(); + } + }; + }) +); diff --git a/core-web/libs/portlets/dot-auth/src/lib/dot-auth-headless-config/dot-auth-headless-config.component.html b/core-web/libs/portlets/dot-auth/src/lib/dot-auth-headless-config/dot-auth-headless-config.component.html new file mode 100644 index 000000000000..0119e271d92e --- /dev/null +++ b/core-web/libs/portlets/dot-auth/src/lib/dot-auth-headless-config/dot-auth-headless-config.component.html @@ -0,0 +1,53 @@ + + + +
+ +
+ +
+
+ + +
+
+ @if (store.headlessDirty()) { + + + {{ 'dotauth.config.unsaved' | dm }} + + } @else { + + {{ 'dotauth.config.saved' | dm }} + + } + @if (store.errorCount() > 0) { + + + {{ store.errorCount() }} + {{ + store.errorCount() === 1 + ? ('dotauth.validation.issue' | dm) + : ('dotauth.validation.issues' | dm) + }} + + } +
+
+ + +
+
diff --git a/core-web/libs/portlets/dot-auth/src/lib/dot-auth-headless-config/dot-auth-headless-config.component.scss b/core-web/libs/portlets/dot-auth/src/lib/dot-auth-headless-config/dot-auth-headless-config.component.scss new file mode 100644 index 000000000000..3d0d6e6786d0 --- /dev/null +++ b/core-web/libs/portlets/dot-auth/src/lib/dot-auth-headless-config/dot-auth-headless-config.component.scss @@ -0,0 +1 @@ +@use "../dot-auth-config/dot-auth-shared"; diff --git a/core-web/libs/portlets/dot-auth/src/lib/dot-auth-headless-config/dot-auth-headless-config.component.ts b/core-web/libs/portlets/dot-auth/src/lib/dot-auth-headless-config/dot-auth-headless-config.component.ts new file mode 100644 index 000000000000..8fdb385872ef --- /dev/null +++ b/core-web/libs/portlets/dot-auth/src/lib/dot-auth-headless-config/dot-auth-headless-config.component.ts @@ -0,0 +1,113 @@ +import { ChangeDetectionStrategy, Component, OnInit, effect, inject, signal } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; + +import { ConfirmationService, MessageService } from 'primeng/api'; +import { ButtonModule } from 'primeng/button'; +import { ConfirmDialogModule } from 'primeng/confirmdialog'; +import { TagModule } from 'primeng/tag'; +import { ToastModule } from 'primeng/toast'; + +import { DotMessageService } from '@dotcms/data-access'; +import { DOT_AUTH_SYSTEM_HOST } from '@dotcms/dotcms-models'; +import { DotMessagePipe } from '@dotcms/ui'; + +import { DotAuthHeadlessSectionComponent, HeadlessChange } from '../dot-auth-config/components'; +import { DotAuthConfigStore } from '../dot-auth-config/store/dot-auth-config.store'; + +@Component({ + selector: 'dot-auth-headless-config', + imports: [ + ButtonModule, + ConfirmDialogModule, + TagModule, + ToastModule, + DotMessagePipe, + DotAuthHeadlessSectionComponent + ], + templateUrl: './dot-auth-headless-config.component.html', + styleUrl: './dot-auth-headless-config.component.scss', + providers: [DotAuthConfigStore, ConfirmationService, MessageService], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class DotAuthHeadlessConfigComponent implements OnInit { + readonly store = inject(DotAuthConfigStore); + + readonly #router = inject(Router); + readonly #route = inject(ActivatedRoute); + readonly #confirm = inject(ConfirmationService); + readonly #messages = inject(MessageService); + readonly #dotMessageService = inject(DotMessageService); + + readonly #pendingSaveToast = signal(false); + + constructor() { + effect(() => { + if (this.store.status() === 'error' && this.#pendingSaveToast()) { + this.#pendingSaveToast.set(false); + return; + } + if (this.store.status() === 'loaded' && this.#pendingSaveToast()) { + this.#pendingSaveToast.set(false); + // Save PUT succeeded; suppress the success toast when the follow-up reload + // failed (it already surfaced an error dialog) to avoid contradictory feedback. + if (!this.store.reloadFailed()) { + this.#messages.add({ + severity: 'success', + summary: this.#dotMessageService.get('dotauth.toast.saved') + }); + } + } + }); + } + + ngOnInit(): void { + this.store.load(DOT_AUTH_SYSTEM_HOST); + } + + save(): void { + const started = this.store.saveHeadless(); + if (started) { + this.#pendingSaveToast.set(true); + } + } + + back(): void { + if (!this.store.headlessDirty()) { + this.#goToList(); + return; + } + this.#confirm.confirm({ + header: this.#dotMessageService.get('dotauth.confirm.discard.header'), + message: this.#dotMessageService.get('dotauth.confirm.leave.message'), + acceptLabel: this.#dotMessageService.get('dotauth.action.leave'), + rejectLabel: this.#dotMessageService.get('Cancel'), + accept: () => this.#goToList() + }); + } + + // Navigate relative to the parent (shell) route rather than a relative + // `['../']`, which trips an infinite loop in Angular's `..` segment + // resolution from multi-segment config routes. + #goToList(): void { + void this.#router.navigate(['.'], { relativeTo: this.#route.parent }); + } + + confirmRevoke(): void { + this.#confirm.confirm({ + header: this.#dotMessageService.get('dotauth.confirm.revoke.header'), + message: this.#dotMessageService.get('dotauth.confirm.revoke.message'), + acceptLabel: this.#dotMessageService.get('dotauth.action.revoke-all'), + rejectLabel: this.#dotMessageService.get('Cancel'), + acceptButtonStyleClass: 'p-button-danger', + accept: () => this.store.revokeAllSessionRefs() + }); + } + + onHeadlessChange(event: HeadlessChange): void { + this.store.update(event.path, event.value); + } + + onDiscoverIdp(index: number): void { + this.store.runOidcDiscovery(`headless.trustedIdps.${index}`); + } +} diff --git a/core-web/libs/portlets/dot-auth/src/lib/dot-auth-list/dot-auth-list.component.html b/core-web/libs/portlets/dot-auth/src/lib/dot-auth-list/dot-auth-list.component.html new file mode 100644 index 000000000000..3fbf084d8321 --- /dev/null +++ b/core-web/libs/portlets/dot-auth/src/lib/dot-auth-list/dot-auth-list.component.html @@ -0,0 +1,352 @@ + + + +
+ @if (!store.system().configured && !store.system().headlessConfigured) { + +
+
+ +
+

+ {{ 'dotauth.empty.identity.title' | dm }} +

+

+ {{ 'dotauth.empty.identity.description' | dm }} +

+
+ + +
+
+ } @else { + +
+
+
+
+ +

+ {{ 'dotauth.system.hero.title' | dm }} +

+ + @if (store.system().headlessConfigured) { + + } +
+

+ {{ 'dotauth.system.hero.subtitle' | dm }} +

+
+
+ +
+ + + @if (protocolLabelKey(store.system().protocol); as protocolKey) { + {{ protocolKey | dm }} + } @else { + {{ 'dotauth.protocol.none' | dm }} + } + +
+ @if (store.system().protocol === 'SAML') { + + } + + +
+
+
+ } + + +
+ +
+ + + + + +
+ + + +
+
+
+ + + + + {{ 'dotauth.table.header.site' | dm }} + {{ 'dotauth.column.protocol' | dm }} + {{ 'dotauth.column.sso' | dm }} + {{ 'dotauth.column.headless' | dm }} + + + + + + @if (store.status() === 'loading') { + + + + + + + + } @else { + + +
+
+ {{ row.hostName.slice(0, 2).toUpperCase() }} +
+
+
+ {{ row.hostName }} +
+
+ {{ row.hostId }} +
+
+
+ + + @if ( + row.status === 'SITE_OVERRIDE' || row.status === 'NOT_CONFIGURED' + ) { + @if (protocolLabelKey(row.protocol); as protocolKey) { + + + {{ protocolKey | dm }} + + } @else { + + } + } @else { + + + {{ 'dotauth.status.inherits' | dm }} + + } + + + + + + + + +
+ @if (row.protocol === 'SAML') { + + } + +
+ + + } +
+ + + + + {{ 'dotauth.empty.state.description' | dm }} + + + +
+
+
+ + + + @switch (status) { + @case ('enabled') { + + + {{ 'dotauth.status.enabled' | dm }} + + } + @case ('override') { + + + {{ 'dotauth.status.overridden' | dm }} + + } + @case ('inherits') { + + + {{ 'dotauth.status.inherits' | dm }} + + } + @default { + + + {{ 'dotauth.status.disabled' | dm }} + + } + } + + + +
+

{{ 'dotauth.export.description' | dm }}

+
+ + + + {{ 'dotauth.export.password.help' | dm }} + +
+
+ + + + +
+ + +
+

{{ 'dotauth.import.description' | dm }}

+
+ + +
+
+ + + + {{ 'dotauth.import.password.help' | dm }} + +
+
+ + + + +
diff --git a/core-web/libs/portlets/dot-auth/src/lib/dot-auth-list/dot-auth-list.component.scss b/core-web/libs/portlets/dot-auth/src/lib/dot-auth-list/dot-auth-list.component.scss new file mode 100644 index 000000000000..8bb9117a4a98 --- /dev/null +++ b/core-web/libs/portlets/dot-auth/src/lib/dot-auth-list/dot-auth-list.component.scss @@ -0,0 +1,219 @@ +:host { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; +} + +.list-page { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + gap: 1.25rem; + padding: 1.25rem 1.5rem; + overflow: auto; + background: var(--surface-card, #fff); +} + +.dialog-form { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.field { + display: flex; + flex-direction: column; + gap: 0.375rem; + + label { + font-size: 0.8125rem; + font-weight: 500; + line-height: 1.3; + } + + input { + width: 100%; + } + + small { + font-size: 0.75rem; + line-height: 1.4; + } +} + +.dialog-help { + margin: 0; + color: var(--text-color-secondary); + font-size: 0.875rem; + line-height: 1.4; +} + +// ── Empty state ────────────────────────────────────────────── + +.empty-state-card { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.875rem; + padding: 3.5rem 1rem; + text-align: center; + background: #fff; + border: 1px solid var(--surface-border, #e2e8f0); + border-radius: 12px; + box-shadow: 0 2px 8px -2px rgba(15, 23, 42, 0.08); +} + +.empty-state-icon { + width: 72px; + height: 72px; + border-radius: 16px; + background: var(--primary-50, #eff6ff); + color: var(--primary-600, #2563eb); + display: grid; + place-items: center; +} + +// ── System hero card ───────────────────────────────────────── + +.system-hero { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 1.25rem; + padding: 1.25rem 1.5rem; + background: linear-gradient( + 135deg, + #ffffff 0%, + var(--primary-50, #eff6ff) 50%, + var(--primary-100, #dbeafe) 100% + ); + border: 1px solid var(--surface-border, #e2e8f0); + border-radius: 12px; + box-shadow: + 0 1px 3px -1px rgba(15, 23, 42, 0.06), + 0 2px 8px -4px rgba(66, 107, 240, 0.06); +} + +.hero-actions { + display: flex; + flex-direction: column; + align-items: flex-end; + justify-content: space-between; + gap: 0.5rem; +} + +.protocol-pill { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.25rem 0.625rem; + background: #fff; + border: 1px solid var(--surface-border, #e2e8f0); + border-radius: 999px; + font-size: 0.75rem; + font-weight: 500; + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04); +} + +// ── Sites table card ───────────────────────────────────────── + +.table-card { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + background: #fff; + border: 1px solid var(--surface-border, #e2e8f0); + border-radius: 12px; + box-shadow: 0 1px 3px -1px rgba(15, 23, 42, 0.06); + overflow: hidden; +} + +:host ::ng-deep .p-toolbar { + border-radius: 0; + background: #fff; + border-left: 0; + border-right: 0; + border-top: 0; +} + +:host ::ng-deep .p-datatable .p-datatable-thead > tr > th { + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-color-secondary, #475569); +} + +.site-avatar { + width: 32px; + height: 32px; + border-radius: 8px; + background: var(--primary-50, #eff6ff); + color: var(--primary-700, #1d4ed8); + display: grid; + place-items: center; + font-size: 0.75rem; + font-weight: 600; + flex-shrink: 0; +} + +// ── Status tags with colored dot ───────────────────────────── + +.status-tag { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.1875rem 0.5rem; + font-size: 0.75rem; + font-weight: 600; + border-radius: 999px; + white-space: nowrap; + + .status-dot { + width: 6px; + height: 6px; + border-radius: 999px; + flex-shrink: 0; + } + + &.success { + color: var(--green-700, #15803d); + background: var(--green-50, #f0fdf4); + .status-dot { + background: var(--green-500, #22c55e); + } + } + + &.info { + color: var(--blue-700, #1d4ed8); + background: var(--blue-50, #eff6ff); + .status-dot { + background: var(--blue-500, #3b82f6); + } + } + + &.muted { + color: var(--text-color-secondary, #475569); + background: var(--gray-100, #f1f5f9); + .status-dot { + background: var(--gray-400, #9ca3af); + } + } +} + +// ── Inherit chip ───────────────────────────────────────────── + +.inherit-chip { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.1875rem 0.5rem; + font-size: 0.75rem; + color: var(--text-color-secondary, #475569); + background: var(--gray-50, #f8fafc); + border: 1px solid var(--surface-border, #e2e8f0); + border-radius: 999px; + font-weight: 500; +} diff --git a/core-web/libs/portlets/dot-auth/src/lib/dot-auth-list/dot-auth-list.component.spec.ts b/core-web/libs/portlets/dot-auth/src/lib/dot-auth-list/dot-auth-list.component.spec.ts new file mode 100644 index 000000000000..3540168e278e --- /dev/null +++ b/core-web/libs/portlets/dot-auth/src/lib/dot-auth-list/dot-auth-list.component.spec.ts @@ -0,0 +1,251 @@ +import { byTestId, createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest'; + +import { ActivatedRoute, Router } from '@angular/router'; + +import { ConfirmationService, MessageService } from 'primeng/api'; + +import { DotAuthService, DotHttpErrorManagerService, DotMessageService } from '@dotcms/data-access'; +import { + DOT_AUTH_SYSTEM_HOST, + DotAuthSiteRow, + DotAuthSystemView, + DotAuthProtocol, + DotAuthStatus +} from '@dotcms/dotcms-models'; +import { MockDotMessageService } from '@dotcms/utils-testing'; + +import { DotAuthListComponent } from './dot-auth-list.component'; +import { DotAuthListStore } from './store/dot-auth-list.store'; + +const ROWS: DotAuthSiteRow[] = [ + { hostId: '1', hostName: 'a.example', status: 'SITE_OVERRIDE', protocol: 'OAUTH' }, + { hostId: '2', hostName: 'b.example', status: 'SITE_OVERRIDE', protocol: 'SAML' }, + { hostId: '3', hostName: 'c.example', status: 'NOT_CONFIGURED', protocol: null }, + { hostId: '4', hostName: 'd.example', status: 'INHERITED', protocol: 'OAUTH' } +]; + +const SYSTEM: DotAuthSystemView = { configured: true, protocol: 'SAML', headlessConfigured: false }; + +const MESSAGES = { + 'dotauth.row.system.label': 'All Sites (system default)', + 'dotauth.banner.system': 'System banner', + 'dotauth.search.placeholder': 'Search', + 'dotauth.table.header.site': 'Site', + 'dotauth.table.header.status': 'Status', + 'dotauth.table.header.actions': 'Actions', + 'dotauth.column.protocol': 'Protocol', + 'dotauth.protocol.oauth': 'OAuth 2.0 / OIDC', + 'dotauth.protocol.saml': 'SAML 2.0', + 'dotauth.status.site-override': 'Site override', + 'dotauth.status.inherited': 'Inherits from System', + 'dotauth.status.not-configured': 'Not configured', + 'dotauth.status.configured': 'Configured', + 'dotauth.action.edit': 'Edit', + 'dotauth.action.configure': 'Configure', + 'dotauth.action.clear': 'Clear', + 'dotauth.empty.state.title': 'No sites yet', + 'dotauth.empty.state.description': 'Once you add sites…', + 'dotauth.filter.all': 'All', + 'dotauth.filter.overrides': 'Overrides', + 'dotauth.filter.sso-on': 'SSO on', + 'dotauth.filter.headless-on': 'Headless on', + 'dotauth.filter.disabled': 'Disabled', + Cancel: 'Cancel' +}; + +describe('DotAuthListComponent', () => { + let spectator: Spectator; + let router: Router; + + const createComponent = createComponentFactory({ + component: DotAuthListComponent, + componentProviders: [ + mockProvider(DotAuthListStore, { + system: jest.fn().mockReturnValue(SYSTEM), + sites: jest.fn().mockReturnValue(ROWS), + filteredSites: jest.fn().mockReturnValue(ROWS), + query: jest.fn().mockReturnValue(''), + filter: jest.fn().mockReturnValue('all'), + status: jest.fn().mockReturnValue('loaded'), + loadSites: jest.fn(), + setQuery: jest.fn(), + setFilter: jest.fn(), + clearSite: jest.fn() + }), + ConfirmationService, + MessageService + ], + providers: [ + mockProvider(Router), + { provide: ActivatedRoute, useValue: {} }, + { provide: DotMessageService, useValue: new MockDotMessageService(MESSAGES) }, + mockProvider(DotAuthService, { + exportBundle: jest.fn().mockResolvedValue(null), + importBundle: jest.fn() + }), + mockProvider(DotHttpErrorManagerService) + ] + }); + + beforeEach(() => { + spectator = createComponent(); + router = spectator.inject(Router); + jest.clearAllMocks(); + }); + + describe('statusTag (severity encodes protocol)', () => { + function tag(status: DotAuthStatus, protocol: DotAuthProtocol | null) { + return spectator.component.statusTag(status, protocol); + } + + it('returns secondary severity for NOT_CONFIGURED regardless of protocol', () => { + expect(tag('NOT_CONFIGURED', null).severity).toBe('secondary'); + }); + + it('returns success severity for OAUTH rows', () => { + expect(tag('SITE_OVERRIDE', 'OAUTH').severity).toBe('success'); + expect(tag('INHERITED', 'OAUTH').severity).toBe('success'); + }); + + it('returns info severity for SAML rows', () => { + expect(tag('SITE_OVERRIDE', 'SAML').severity).toBe('info'); + expect(tag('INHERITED', 'SAML').severity).toBe('info'); + }); + + it('picks site-override vs inherited label from status', () => { + expect(tag('SITE_OVERRIDE', 'OAUTH').labelKey).toBe('dotauth.status.site-override'); + expect(tag('INHERITED', 'SAML').labelKey).toBe('dotauth.status.inherited'); + }); + }); + + describe('systemStatusTag', () => { + it('renders info severity for SAML system default', () => { + expect(spectator.component.$systemStatusTag()).toEqual({ + labelKey: 'dotauth.status.configured', + severity: 'info' + }); + }); + }); + + describe('protocolLabelKey', () => { + it('returns null when protocol is null', () => { + expect(spectator.component.protocolLabelKey(null)).toBeNull(); + }); + + it('returns oauth key for OAUTH', () => { + expect(spectator.component.protocolLabelKey('OAUTH')).toBe('dotauth.protocol.oauth'); + }); + + it('returns saml key for SAML', () => { + expect(spectator.component.protocolLabelKey('SAML')).toBe('dotauth.protocol.saml'); + }); + }); + + describe('Protocol column rendering', () => { + it('renders a Protocol cell per configured row (hides when null)', () => { + const cells = spectator.queryAll(byTestId('dotauth-protocol-cell')); + expect(cells.length).toBe(2); + expect(cells[0].textContent?.trim()).toContain('OAuth'); + expect(cells[1].textContent?.trim()).toContain('SAML'); + }); + + it('renders the SYSTEM protocol label in the system card', () => { + const systemProtocol = spectator.query(byTestId('dotauth-system-protocol')); + expect(systemProtocol?.textContent?.trim()).toContain('SAML'); + }); + }); + + describe('navigation', () => { + it('openSystemConfig navigates to site/SYSTEM_HOST', () => { + spectator.component.openSystemConfig(); + + expect(router.navigate).toHaveBeenCalledWith( + ['site', DOT_AUTH_SYSTEM_HOST], + expect.objectContaining({ relativeTo: expect.anything() }) + ); + }); + + it('openHeadlessConfig navigates to headless', () => { + spectator.component.openHeadlessConfig(); + + expect(router.navigate).toHaveBeenCalledWith( + ['headless'], + expect.objectContaining({ relativeTo: expect.anything() }) + ); + }); + + it('openSiteConfig navigates to site/:hostId', () => { + spectator.component.openSiteConfig('1'); + + expect(router.navigate).toHaveBeenCalledWith( + ['site', '1'], + expect.objectContaining({ relativeTo: expect.anything() }) + ); + }); + }); + + describe('confirmClearSystem / confirmClearSite', () => { + it('clears the system row when the confirmation is accepted', () => { + const confirm = spectator.inject(ConfirmationService, true); + jest.spyOn(confirm, 'confirm').mockImplementation((opts) => { + opts.accept?.(); + return confirm; + }); + + spectator.component.confirmClearSystem(); + + expect(spectator.component.store.clearSite).toHaveBeenCalledWith('SYSTEM_HOST'); + }); + + it('does not clear the system row when rejected', () => { + const confirm = spectator.inject(ConfirmationService, true); + jest.spyOn(confirm, 'confirm').mockImplementation((opts) => { + opts.reject?.(); + return confirm; + }); + + spectator.component.confirmClearSystem(); + + expect(spectator.component.store.clearSite).not.toHaveBeenCalled(); + }); + + it('clears a site row when the confirmation is accepted', () => { + const confirm = spectator.inject(ConfirmationService, true); + jest.spyOn(confirm, 'confirm').mockImplementation((opts) => { + opts.accept?.(); + return confirm; + }); + + spectator.component.confirmClearSite(ROWS[0]); + + expect(spectator.component.store.clearSite).toHaveBeenCalledWith('1'); + }); + }); + + describe('onSearch (300ms debounce)', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + it('does not forward to setQuery until 300ms of silence', () => { + spectator.component.onSearch('a'); + spectator.component.onSearch('ab'); + spectator.component.onSearch('abc'); + + expect(spectator.component.store.setQuery).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(300); + + expect(spectator.component.store.setQuery).toHaveBeenCalledTimes(1); + expect(spectator.component.store.setQuery).toHaveBeenLastCalledWith('abc'); + }); + + it('dedupes back-to-back equal values (distinctUntilChanged)', () => { + spectator.component.onSearch('a'); + jest.advanceTimersByTime(300); + spectator.component.onSearch('a'); + jest.advanceTimersByTime(300); + + expect(spectator.component.store.setQuery).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/core-web/libs/portlets/dot-auth/src/lib/dot-auth-list/dot-auth-list.component.ts b/core-web/libs/portlets/dot-auth/src/lib/dot-auth-list/dot-auth-list.component.ts new file mode 100644 index 000000000000..8e0d31a038b3 --- /dev/null +++ b/core-web/libs/portlets/dot-auth/src/lib/dot-auth-list/dot-auth-list.component.ts @@ -0,0 +1,263 @@ +import { Subject } from 'rxjs'; + +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + computed, + inject, + signal +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { FormsModule } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; + +import { ConfirmationService, MessageService } from 'primeng/api'; +import { ButtonModule } from 'primeng/button'; +import { ConfirmDialogModule } from 'primeng/confirmdialog'; +import { DialogModule } from 'primeng/dialog'; +import { IconFieldModule } from 'primeng/iconfield'; +import { InputIconModule } from 'primeng/inputicon'; +import { InputTextModule } from 'primeng/inputtext'; +import { SelectButtonModule } from 'primeng/selectbutton'; +import { SkeletonModule } from 'primeng/skeleton'; +import { TableModule } from 'primeng/table'; +import { TagModule } from 'primeng/tag'; +import { ToastModule } from 'primeng/toast'; +import { ToolbarModule } from 'primeng/toolbar'; +import { TooltipModule } from 'primeng/tooltip'; + +import { debounceTime, distinctUntilChanged } from 'rxjs/operators'; + +import { DotAuthService, DotHttpErrorManagerService, DotMessageService } from '@dotcms/data-access'; +import { + DOT_AUTH_SYSTEM_HOST, + DotAuthListFilter, + DotAuthProtocol, + DotAuthStatus +} from '@dotcms/dotcms-models'; +import { DotMessagePipe } from '@dotcms/ui'; + +import { DotAuthListStore } from './store/dot-auth-list.store'; + +interface StatusTag { + labelKey: string; + severity: 'success' | 'info' | 'secondary'; +} + +@Component({ + selector: 'dot-auth-list', + imports: [ + CommonModule, + FormsModule, + TableModule, + ButtonModule, + ConfirmDialogModule, + DialogModule, + TagModule, + InputTextModule, + IconFieldModule, + InputIconModule, + SelectButtonModule, + SkeletonModule, + ToastModule, + ToolbarModule, + TooltipModule, + DotMessagePipe + ], + templateUrl: './dot-auth-list.component.html', + styleUrl: './dot-auth-list.component.scss', + providers: [DotAuthListStore, ConfirmationService, MessageService], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class DotAuthListComponent { + readonly SYSTEM_HOST = DOT_AUTH_SYSTEM_HOST; + readonly store = inject(DotAuthListStore); + + readonly #router = inject(Router); + readonly #route = inject(ActivatedRoute); + readonly #confirmationService = inject(ConfirmationService); + readonly #dotMessageService = inject(DotMessageService); + readonly #dotAuthService = inject(DotAuthService); + readonly #httpErrorManager = inject(DotHttpErrorManagerService); + readonly #messages = inject(MessageService); + readonly #destroyRef = inject(DestroyRef); + + readonly #searchSubject = new Subject(); + readonly exportDialogOpen = signal(false); + readonly importDialogOpen = signal(false); + readonly exportPassword = signal(''); + readonly importPassword = signal(''); + readonly importFile = signal(null); + readonly transferBusy = signal(false); + + readonly exportPasswordValid = computed(() => + this.isValidExportPassword(this.exportPassword()) + ); + readonly importFormValid = computed( + () => this.isValidExportPassword(this.importPassword()) && this.importFile() !== null + ); + + readonly filterOptions = computed(() => { + const m = (key: string) => this.#dotMessageService.get(key); + return [ + { label: m('dotauth.filter.all'), value: 'all' as DotAuthListFilter }, + { label: m('dotauth.filter.overrides'), value: 'overrides' as DotAuthListFilter }, + { label: m('dotauth.filter.sso-on'), value: 'sso-on' as DotAuthListFilter }, + { label: m('dotauth.filter.headless-on'), value: 'headless-on' as DotAuthListFilter }, + { label: m('dotauth.filter.disabled'), value: 'disabled' as DotAuthListFilter } + ]; + }); + + readonly $systemStatusTag = computed(() => { + const system = this.store.system(); + if (!system.configured) { + return { labelKey: 'dotauth.status.not-configured', severity: 'secondary' }; + } + const severity: 'success' | 'info' = system.protocol === 'OAUTH' ? 'success' : 'info'; + return { labelKey: 'dotauth.status.configured', severity }; + }); + + constructor() { + this.#searchSubject + .pipe(debounceTime(300), distinctUntilChanged(), takeUntilDestroyed(this.#destroyRef)) + .subscribe((value) => this.store.setQuery(value)); + } + + onSearch(value: string): void { + this.#searchSubject.next(value); + } + + statusTag(status: DotAuthStatus, protocol: DotAuthProtocol | null): StatusTag { + if (status === 'NOT_CONFIGURED') { + return { labelKey: 'dotauth.status.not-configured', severity: 'secondary' }; + } + const severity: 'success' | 'info' = protocol === 'OAUTH' ? 'success' : 'info'; + const labelKey = + status === 'SITE_OVERRIDE' + ? 'dotauth.status.site-override' + : 'dotauth.status.inherited'; + return { labelKey, severity }; + } + + protocolLabelKey(protocol: DotAuthProtocol | null): string | null { + if (!protocol) { + return null; + } + return protocol === 'OAUTH' ? 'dotauth.protocol.oauth' : 'dotauth.protocol.saml'; + } + + protocolColor(protocol: DotAuthProtocol | null): string { + if (protocol === 'OAUTH') return '#426BF0'; + if (protocol === 'SAML') return '#8b5cf6'; + return '#94A3B8'; + } + + openSystemConfig(): void { + void this.#router.navigate(['site', this.SYSTEM_HOST], { relativeTo: this.#route }); + } + + openHeadlessConfig(): void { + void this.#router.navigate(['headless'], { relativeTo: this.#route }); + } + + openSiteConfig(hostId: string): void { + void this.#router.navigate(['site', hostId], { relativeTo: this.#route }); + } + + openExportDialog(): void { + this.exportPassword.set(''); + this.exportDialogOpen.set(true); + } + + openImportDialog(): void { + this.importPassword.set(''); + this.importFile.set(null); + this.importDialogOpen.set(true); + } + + exportBundle(): void { + if (!this.exportPasswordValid() || this.transferBusy()) { + return; + } + this.transferBusy.set(true); + void this.#dotAuthService.exportBundle(this.exportPassword()).then((error) => { + this.transferBusy.set(false); + if (error) { + this.#messages.add({ + severity: 'error', + summary: this.#dotMessageService.get('dotauth.export.error'), + detail: error + }); + return; + } + this.exportDialogOpen.set(false); + this.#messages.add({ + severity: 'success', + summary: this.#dotMessageService.get('dotauth.export.success') + }); + }); + } + + importBundle(): void { + const file = this.importFile(); + if (!this.importFormValid() || this.transferBusy() || file === null) { + return; + } + this.transferBusy.set(true); + this.#dotAuthService + .importBundle(this.importPassword(), file) + .pipe(takeUntilDestroyed(this.#destroyRef)) + .subscribe({ + next: () => { + this.transferBusy.set(false); + this.importDialogOpen.set(false); + this.store.loadSites(); + this.#messages.add({ + severity: 'success', + summary: this.#dotMessageService.get('dotauth.import.success') + }); + }, + error: (error) => { + this.transferBusy.set(false); + this.#httpErrorManager.handle(error); + } + }); + } + + onImportFileChange(event: Event): void { + const input = event.target as HTMLInputElement; + this.importFile.set(input.files?.[0] ?? null); + } + + downloadSamlMetadata(hostId: string): void { + this.#dotAuthService.downloadSamlMetadata(hostId); + } + + confirmClearSystem(): void { + this.#confirmationService.confirm({ + header: this.#dotMessageService.get('dotauth.confirm.clear.header'), + message: this.#dotMessageService.get('dotauth.confirm.clear-system.message'), + acceptLabel: this.#dotMessageService.get('dotauth.action.clear'), + rejectLabel: this.#dotMessageService.get('Cancel'), + acceptButtonStyleClass: 'p-button-danger', + accept: () => this.store.clearSite(this.SYSTEM_HOST) + }); + } + + confirmClearSite(row: { hostId: string }): void { + this.#confirmationService.confirm({ + header: this.#dotMessageService.get('dotauth.confirm.clear.header'), + message: this.#dotMessageService.get('dotauth.confirm.clear-site.message'), + acceptLabel: this.#dotMessageService.get('dotauth.action.clear'), + rejectLabel: this.#dotMessageService.get('Cancel'), + acceptButtonStyleClass: 'p-button-danger', + accept: () => this.store.clearSite(row.hostId) + }); + } + + private isValidExportPassword(password: string): boolean { + return password.length >= 14 && password.length <= 32; + } +} diff --git a/core-web/libs/portlets/dot-auth/src/lib/dot-auth-list/store/dot-auth-list.store.spec.ts b/core-web/libs/portlets/dot-auth/src/lib/dot-auth-list/store/dot-auth-list.store.spec.ts new file mode 100644 index 000000000000..09cbe9af5644 --- /dev/null +++ b/core-web/libs/portlets/dot-auth/src/lib/dot-auth-list/store/dot-auth-list.store.spec.ts @@ -0,0 +1,142 @@ +import { createServiceFactory, mockProvider, SpectatorService } from '@ngneat/spectator/jest'; +import { of, throwError } from 'rxjs'; + +import { DotAuthService, DotHttpErrorManagerService } from '@dotcms/data-access'; +import { DotAuthSitesView } from '@dotcms/dotcms-models'; + +import { DotAuthListStore } from './dot-auth-list.store'; + +const FIXTURE: DotAuthSitesView = { + system: { configured: true, protocol: 'SAML', headlessConfigured: false }, + sites: [ + { hostId: '1', hostName: 'a.example', status: 'SITE_OVERRIDE', protocol: 'OAUTH' }, + { hostId: '2', hostName: 'b.example', status: 'INHERITED', protocol: 'SAML' }, + { hostId: '3', hostName: 'c.example', status: 'NOT_CONFIGURED', protocol: null } + ] +}; + +describe('DotAuthListStore', () => { + let spectator: SpectatorService>; + let store: InstanceType; + let service: jest.Mocked; + + const createService = createServiceFactory({ + service: DotAuthListStore, + providers: [ + mockProvider(DotAuthService, { + listSites: jest.fn().mockReturnValue(of(FIXTURE)), + saveConfig: jest.fn().mockReturnValue(of(undefined)), + clearConfig: jest.fn().mockReturnValue(of(undefined)) + }), + mockProvider(DotHttpErrorManagerService) + ] + }); + + beforeEach(() => { + spectator = createService(); + store = spectator.service; + service = spectator.inject(DotAuthService) as jest.Mocked; + // Force the withHooks onInit effect to fire deterministically; otherwise + // the first assertions race the scheduler. + spectator.flushEffects(); + }); + + describe('initial state', () => { + it('populates the system row from the REST view after onInit', () => { + expect(store.system()).toEqual({ + configured: true, + protocol: 'SAML', + headlessConfigured: false + }); + }); + }); + + describe('loadSites', () => { + it('populates sites with protocol carried through from the REST view', () => { + expect(store.sites()).toEqual(FIXTURE.sites); + expect(store.sites()[0].protocol).toBe('OAUTH'); + expect(store.sites()[1].protocol).toBe('SAML'); + expect(store.sites()[2].protocol).toBeNull(); + expect(store.status()).toBe('loaded'); + }); + }); + + describe('filteredSites', () => { + it('returns all rows when filter is empty', () => { + expect(store.filteredSites()).toHaveLength(3); + }); + + it('filters by hostName case-insensitively', () => { + store.setFilter('A.EXAMPLE'); + expect(store.filteredSites()).toHaveLength(1); + expect(store.filteredSites()[0].hostName).toBe('a.example'); + }); + }); + + describe('saveSite', () => { + it('emits an OAUTH payload and reloads on success', () => { + service.listSites.mockClear(); + store.saveSite('1', { protocol: 'OAUTH', values: { clientId: 'abc' } }); + + expect(service.saveConfig).toHaveBeenCalledWith('1', { + protocol: 'OAUTH', + values: { clientId: 'abc' } + }); + expect(service.listSites).toHaveBeenCalled(); + }); + + it('emits a SAML payload and reloads on success', () => { + service.listSites.mockClear(); + store.saveSite('2', { protocol: 'SAML', values: { idpName: 'Okta' } }); + + expect(service.saveConfig).toHaveBeenCalledWith('2', { + protocol: 'SAML', + values: { idpName: 'Okta' } + }); + expect(service.listSites).toHaveBeenCalled(); + }); + + it('resets status to loaded on save error', () => { + service.saveConfig.mockReturnValue(throwError(() => new Error('save fail'))); + store.saveSite('1', { protocol: 'OAUTH', values: {} }); + + expect(spectator.inject(DotHttpErrorManagerService).handle).toHaveBeenCalled(); + expect(store.status()).toBe('loaded'); + }); + }); + + describe('clearSite', () => { + it('calls clearConfig and reloads', () => { + service.listSites.mockClear(); + store.clearSite('1'); + + expect(service.clearConfig).toHaveBeenCalledWith('1'); + expect(service.listSites).toHaveBeenCalled(); + }); + + it('resets status to loaded on clearConfig error', () => { + service.clearConfig.mockReturnValue(throwError(() => new Error('clear fail'))); + store.clearSite('1'); + + expect(spectator.inject(DotHttpErrorManagerService).handle).toHaveBeenCalled(); + expect(store.status()).toBe('loaded'); + }); + }); + + describe('loadSites error path', () => { + // Restore the happy-path mock in afterEach so the next describe block + // starts from a clean state — otherwise the throwError() below poisons + // the shared jest.fn() and later tests see errors during onInit. + afterEach(() => { + service.listSites.mockReturnValue(of(FIXTURE)); + }); + + it('resets status to loaded so the list stays usable', () => { + service.listSites.mockReturnValue(throwError(() => new Error('fail'))); + store.loadSites(); + + expect(store.status()).toBe('loaded'); + expect(spectator.inject(DotHttpErrorManagerService).handle).toHaveBeenCalled(); + }); + }); +}); diff --git a/core-web/libs/portlets/dot-auth/src/lib/dot-auth-list/store/dot-auth-list.store.ts b/core-web/libs/portlets/dot-auth/src/lib/dot-auth-list/store/dot-auth-list.store.ts new file mode 100644 index 000000000000..10fdf9a1027b --- /dev/null +++ b/core-web/libs/portlets/dot-auth/src/lib/dot-auth-list/store/dot-auth-list.store.ts @@ -0,0 +1,158 @@ +import { + patchState, + signalStore, + withComputed, + withHooks, + withMethods, + withState +} from '@ngrx/signals'; +import { EMPTY, Observable } from 'rxjs'; + +import { computed, inject } from '@angular/core'; + +import { catchError, take } from 'rxjs/operators'; + +import { DotAuthService, DotHttpErrorManagerService } from '@dotcms/data-access'; +import { + DotAuthCapabilityStatus, + DotAuthConfigPayload, + DotAuthListFilter, + DotAuthSiteRow, + DotAuthSystemView +} from '@dotcms/dotcms-models'; + +type DotAuthListStatus = 'init' | 'loading' | 'loaded'; + +interface DotAuthListState { + system: DotAuthSystemView; + sites: DotAuthSiteRow[]; + query: string; + filter: DotAuthListFilter; + status: DotAuthListStatus; +} + +const initialState: DotAuthListState = { + system: { configured: false, protocol: null, headlessConfigured: false }, + sites: [], + query: '', + filter: 'all', + status: 'init' +}; + +export const DotAuthListStore = signalStore( + withState(initialState), + withComputed((store) => ({ + /** Sites filtered client-side by hostname match. Case-insensitive. */ + filteredSites: computed(() => { + const query = store.query().trim().toLowerCase(); + const mode = store.filter(); + return store + .sites() + .map((row) => ({ + ...row, + ssoStatus: ssoStatus(row), + headlessStatus: store.system().headlessConfigured + ? ('inherits' as DotAuthCapabilityStatus) + : ('disabled' as DotAuthCapabilityStatus) + })) + .filter((row) => !query || row.hostName.toLowerCase().includes(query)) + .filter((row) => { + if (mode === 'overrides') { + return row.ssoStatus === 'override' || row.headlessStatus === 'override'; + } + if (mode === 'sso-on') { + return ( + row.ssoStatus === 'enabled' || + row.ssoStatus === 'override' || + row.ssoStatus === 'inherits' + ); + } + if (mode === 'headless-on') { + return ( + row.headlessStatus === 'enabled' || + row.headlessStatus === 'override' || + row.headlessStatus === 'inherits' + ); + } + if (mode === 'disabled') { + return row.ssoStatus === 'disabled' && row.headlessStatus === 'disabled'; + } + return true; + }); + }) + })), + withMethods((store) => { + const service = inject(DotAuthService); + const httpErrorManager = inject(DotHttpErrorManagerService); + + function loadSites(): void { + patchState(store, { status: 'loading' }); + service + .listSites() + .pipe( + take(1), + catchError((error) => { + httpErrorManager.handle(error); + patchState(store, { status: 'loaded' }); + return EMPTY; + }) + ) + .subscribe((view) => { + patchState(store, { + system: view.system, + sites: view.sites, + status: 'loaded' + }); + }); + } + + function handle(source$: Observable, onSuccess: () => void): void { + patchState(store, { status: 'loading' }); + source$ + .pipe( + take(1), + catchError((error) => { + httpErrorManager.handle(error); + patchState(store, { status: 'loaded' }); + return EMPTY; + }) + ) + .subscribe(() => onSuccess()); + } + + return { + loadSites, + + setQuery(query: string): void { + patchState(store, { query }); + }, + + setFilter(filter: DotAuthListFilter | string): void { + if (['all', 'overrides', 'sso-on', 'headless-on', 'disabled'].includes(filter)) { + patchState(store, { filter: filter as DotAuthListFilter }); + return; + } + patchState(store, { query: filter }); + }, + + saveSite(hostId: string, payload: DotAuthConfigPayload): void { + handle(service.saveConfig(hostId, payload), () => loadSites()); + }, + + clearSite(hostId: string): void { + handle(service.clearConfig(hostId), () => loadSites()); + } + }; + }), + withHooks((store) => ({ + onInit() { + store.loadSites(); + } + })) +); + +function ssoStatus(row: DotAuthSiteRow): DotAuthCapabilityStatus { + if (row.status === 'SITE_OVERRIDE') return row.protocol ? 'override' : 'disabled'; + if (row.status === 'INHERITED') return 'inherits'; + return 'disabled'; +} diff --git a/core-web/libs/portlets/dot-auth/src/lib/dot-auth-shell/dot-auth-shell.component.html b/core-web/libs/portlets/dot-auth/src/lib/dot-auth-shell/dot-auth-shell.component.html new file mode 100644 index 000000000000..67e7bd4cd6a1 --- /dev/null +++ b/core-web/libs/portlets/dot-auth/src/lib/dot-auth-shell/dot-auth-shell.component.html @@ -0,0 +1 @@ + diff --git a/core-web/libs/portlets/dot-auth/src/lib/dot-auth-shell/dot-auth-shell.component.ts b/core-web/libs/portlets/dot-auth/src/lib/dot-auth-shell/dot-auth-shell.component.ts new file mode 100644 index 000000000000..fb446a94ab03 --- /dev/null +++ b/core-web/libs/portlets/dot-auth/src/lib/dot-auth-shell/dot-auth-shell.component.ts @@ -0,0 +1,11 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { RouterOutlet } from '@angular/router'; + +@Component({ + selector: 'dot-auth-shell', + imports: [RouterOutlet], + templateUrl: './dot-auth-shell.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + host: { class: 'flex flex-col h-full min-h-0' } +}) +export class DotAuthShellComponent {} diff --git a/core-web/libs/portlets/dot-auth/src/lib/lib.routes.ts b/core-web/libs/portlets/dot-auth/src/lib/lib.routes.ts new file mode 100644 index 000000000000..68c588d40c65 --- /dev/null +++ b/core-web/libs/portlets/dot-auth/src/lib/lib.routes.ts @@ -0,0 +1,34 @@ +import { Routes } from '@angular/router'; + +export const dotAuthRoutes: Routes = [ + { + path: '', + loadComponent: () => + import('./dot-auth-shell/dot-auth-shell.component').then( + (m) => m.DotAuthShellComponent + ), + children: [ + { + path: '', + loadComponent: () => + import('./dot-auth-list/dot-auth-list.component').then( + (m) => m.DotAuthListComponent + ) + }, + { + path: 'site/:hostId', + loadComponent: () => + import('./dot-auth-config/dot-auth-config.component').then( + (m) => m.DotAuthConfigComponent + ) + }, + { + path: 'headless', + loadComponent: () => + import('./dot-auth-headless-config/dot-auth-headless-config.component').then( + (m) => m.DotAuthHeadlessConfigComponent + ) + } + ] + } +]; diff --git a/core-web/libs/portlets/dot-auth/src/test-setup.ts b/core-web/libs/portlets/dot-auth/src/test-setup.ts new file mode 100644 index 000000000000..c2cfc250dedd --- /dev/null +++ b/core-web/libs/portlets/dot-auth/src/test-setup.ts @@ -0,0 +1,11 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone'; + +setupZoneTestEnv({ + errorOnUnknownElements: true, + errorOnUnknownProperties: true +}); + +if (!global.structuredClone) { + global.structuredClone = (obj: any) => JSON.parse(JSON.stringify(obj)); +} diff --git a/core-web/libs/portlets/dot-auth/tsconfig.json b/core-web/libs/portlets/dot-auth/tsconfig.json new file mode 100644 index 000000000000..2ac0a4fd922f --- /dev/null +++ b/core-web/libs/portlets/dot-auth/tsconfig.json @@ -0,0 +1,29 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "isolatedModules": true, + "target": "es2022", + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "module": "preserve" + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/core-web/libs/portlets/dot-auth/tsconfig.lib.json b/core-web/libs/portlets/dot-auth/tsconfig.lib.json new file mode 100644 index 000000000000..816bd7adebd7 --- /dev/null +++ b/core-web/libs/portlets/dot-auth/tsconfig.lib.json @@ -0,0 +1,18 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "types": [] + }, + "include": ["src/**/*.ts"], + "exclude": [ + "src/**/*.spec.ts", + "src/**/*.test.ts", + "jest.config.ts", + "jest.config.cts", + "src/test-setup.ts" + ] +} diff --git a/core-web/libs/portlets/dot-auth/tsconfig.spec.json b/core-web/libs/portlets/dot-auth/tsconfig.spec.json new file mode 100644 index 000000000000..ae96844742c9 --- /dev/null +++ b/core-web/libs/portlets/dot-auth/tsconfig.spec.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "module": "commonjs", + "target": "es2016", + "types": ["jest", "node"], + "moduleResolution": "node10", + "isolatedModules": true + }, + "files": ["src/test-setup.ts"], + "include": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"] +} diff --git a/core-web/package.json b/core-web/package.json index 1d85a552d6ac..d529c7f8a8ca 100644 --- a/core-web/package.json +++ b/core-web/package.json @@ -281,6 +281,7 @@ "jest-html-reporters": "3.1.5", "jest-junit": "16.0.0", "jest-preset-angular": "16.0.0", + "jest-util": "^30.0.2", "jiti": "2.4.2", "jsdom": "28.1.0", "jsonc-eslint-parser": "2.4.0", diff --git a/core-web/pnpm-lock.yaml b/core-web/pnpm-lock.yaml index 6c31ab6bf331..c01a03d37fd2 100644 --- a/core-web/pnpm-lock.yaml +++ b/core-web/pnpm-lock.yaml @@ -707,6 +707,9 @@ importers: jest-preset-angular: specifier: 16.0.0 version: 16.0.0(db4775bb8e1d7033ec04c76ee442fbb7) + jest-util: + specifier: ^30.0.2 + version: 30.3.0 jiti: specifier: 2.4.2 version: 2.4.2 @@ -1052,6 +1055,7 @@ packages: '@angular/animations@21.2.4': resolution: {integrity: sha512-hO1P7ks9n7lW3D31bzHohSuoAaj05xJUlK8rZgX8IkH5DLx4qhvfNh0t4bbLuBJLP2r1TaLsQ8KFcemCkFRO2w==, tarball: https://registry.npmjs.org/@angular/animations/-/animations-21.2.4.tgz} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + deprecated: '@angular/animations is deprecated. Use `animate.enter` and `animate.leave` instead. For more information see: https://v22.angular.dev/guide/animations.' peerDependencies: '@angular/core': 21.2.4 @@ -1172,6 +1176,7 @@ packages: '@angular/platform-browser-dynamic@21.2.4': resolution: {integrity: sha512-LRJLnGh4rdgD0+S5xuDd4YRm5bV8WP2e6F1Pe5rIr6N4V9ofgpB0/uOjYy9se99FJZjoyPnpxaKsp8+XA753Zg==, tarball: https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-21.2.4.tgz} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + deprecated: '@angular/platform-browser-dynamic is deprecated. Use `@angular/platform-browser` instead.' peerDependencies: '@angular/common': 21.2.4 '@angular/compiler': 21.2.4 @@ -6958,6 +6963,7 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==, tarball: https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz} + deprecated: Potential CWE-502 - Update to 1.3.1 or higher '@unrs/resolver-binding-android-arm-eabi@1.11.1': resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==, tarball: https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz} @@ -11425,6 +11431,7 @@ packages: next@14.0.4: resolution: {integrity: sha512-qbwypnM7327SadwFtxXnQdGiKpkuhaRLE2uq62/nRul9cj9KhQ5LhHmlziTNqUidZotw/Q1I9OjirBROdUJNgA==, tarball: https://registry.npmjs.org/next/-/next-14.0.4.tgz} engines: {node: '>=18.17.0'} + deprecated: This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/security-update-2025-12-11 for more details. hasBin: true peerDependencies: '@opentelemetry/api': ^1.1.0 @@ -14180,10 +14187,12 @@ packages: uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==, tarball: https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true uuid@9.0.0: resolution: {integrity: sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==, tarball: https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true v8-compile-cache-lib@3.0.1: diff --git a/core-web/tsconfig.base.json b/core-web/tsconfig.base.json index 9385bd89c861..ebc07f4b485b 100644 --- a/core-web/tsconfig.base.json +++ b/core-web/tsconfig.base.json @@ -71,6 +71,7 @@ "@dotcms/portlets/dot-locales/portlet/data-access": [ "libs/portlets/dot-locales/data-access/src/index.ts" ], + "@dotcms/portlets/dot-auth/portlet": ["libs/portlets/dot-auth/src/index.ts"], "@dotcms/portlets/dot-plugins/portlet": ["libs/portlets/dot-plugins/src/index.ts"], "@dotcms/portlets/dot-es-search/portlet": ["libs/portlets/dot-es-search/src/index.ts"], "@dotcms/portlets/dot-query-tool/portlet": [ diff --git a/dotCMS/pom.xml b/dotCMS/pom.xml index 7cd8f4c37d56..2b584310aea3 100644 --- a/dotCMS/pom.xml +++ b/dotCMS/pom.xml @@ -1057,6 +1057,11 @@ jjwt
+ + com.nimbusds + nimbus-jose-jwt + + org.bouncycastle bcpkix-jdk18on @@ -1969,6 +1974,7 @@ + ${dotcms.image.name} ${project.basedir}/src/main/docker/original @@ -2147,6 +2153,7 @@ com.dotcms.rendering.js com.dotcms.ai.rest com.dotcms.health + com.dotcms.auth.dotAuth.rest diff --git a/dotCMS/src/main/java/com/dotcms/auth/AuthAccessDeniedUtil.java b/dotCMS/src/main/java/com/dotcms/auth/AuthAccessDeniedUtil.java new file mode 100644 index 000000000000..372011f5b29c --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/auth/AuthAccessDeniedUtil.java @@ -0,0 +1,57 @@ +package com.dotcms.auth; + +import com.dotmarketing.util.UtilHTML; +import com.liferay.portal.model.User; + +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +public final class AuthAccessDeniedUtil { + + private AuthAccessDeniedUtil() { + } + + public static boolean hasRequiredRole(final User user, final boolean frontEndLogin) { + if (user == null) { + return false; + } + return frontEndLogin + ? user.isFrontendUser() + : user.isBackendUser() || user.isAdmin(); + } + + public static void sendNoAccessPage(final HttpServletResponse response, + final User user) throws IOException { + setNoCacheHeaders(response); + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + response.setContentType("text/html;charset=UTF-8"); + final String email = user != null && user.getEmailAddress() != null + ? UtilHTML.escapeHTMLSpecialChars(user.getEmailAddress()) + : ""; + response.getWriter().write( + "Access denied" + + "" + + "" + + "
" + + "

Your account does not have access to dotCMS

" + + "

You signed in as " + email + " " + + "but this account has not been granted the required permissions. " + + "Contact your administrator to request access.

" + + "
"); + } + + public static void setNoCacheHeaders(final HttpServletResponse response) { + response.setHeader("Cache-Control", "no-cache, no-store, must-revalidate"); + response.setHeader("Pragma", "no-cache"); + response.setDateHeader("Expires", 0); + } +} diff --git a/dotCMS/src/main/java/com/dotcms/auth/dotAuth/DotAuthConstants.java b/dotCMS/src/main/java/com/dotcms/auth/dotAuth/DotAuthConstants.java new file mode 100644 index 000000000000..ff30f516a779 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/auth/dotAuth/DotAuthConstants.java @@ -0,0 +1,26 @@ +package com.dotcms.auth.dotAuth; + +/** + * Constants for the {@code dotAuth} App/portlet. Single source of truth for the + * OAuth-flavor AppSecrets key (SAML uses its own {@code dotsaml-config} key from + * {@link com.dotcms.saml.DotSamlProxyFactory#SAML_APP_CONFIG_KEY}). + */ +public final class DotAuthConstants { + + /** AppSecrets key under which the OAuth SSO runtime stores its config. */ + public static final String APP_KEY = "dotAuth"; + + /** AppSecrets key for headless token-exchange config (separate from SSO). */ + public static final String HEADLESS_APP_KEY = "dotauth-headless"; + + /** Internal metadata key used to resolve temporary SSO protocol overlap after save. */ + public static final String LAST_SAVED_PROTOCOL_AT_KEY = "__dotauthLastSavedProtocolAt"; + + /** + * Value returned for hidden secrets in the dotAuth REST surface. When a client + * posts this value back on a hidden key, the stored secret is preserved. + */ + public static final String HIDDEN_SECRET_MASK = "****"; + + private DotAuthConstants() {} +} diff --git a/dotCMS/src/main/java/com/dotcms/auth/dotAuth/rest/DotAuthConfigForm.java b/dotCMS/src/main/java/com/dotcms/auth/dotAuth/rest/DotAuthConfigForm.java new file mode 100644 index 000000000000..60ec6be7892e --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/auth/dotAuth/rest/DotAuthConfigForm.java @@ -0,0 +1,38 @@ +package com.dotcms.auth.dotAuth.rest; + +import com.dotcms.rest.api.Validated; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.Map; +import javax.validation.constraints.NotNull; + +/** + * Body of {@code PUT /v1/dotauth/sites/{hostId}}. Values mirror the secret + * keys for the chosen {@code protocol}. A hidden-secret value of {@code "****"} + * is treated by the resource as "preserve the stored value". + * + *

If {@code protocol} is absent from the JSON body the form falls back to + * {@link DotAuthProtocol#OAUTH}, preserving the phase-2 wire contract. + */ +public class DotAuthConfigForm extends Validated { + + private final DotAuthProtocol protocol; + + @NotNull + private final Map values; + + @JsonCreator + public DotAuthConfigForm(@JsonProperty("protocol") final DotAuthProtocol protocol, + @JsonProperty("values") final Map values) { + this.protocol = protocol == null ? DotAuthProtocol.OAUTH : protocol; + this.values = values; + } + + public DotAuthProtocol getProtocol() { + return protocol; + } + + public Map getValues() { + return values; + } +} diff --git a/dotCMS/src/main/java/com/dotcms/auth/dotAuth/rest/DotAuthConfigView.java b/dotCMS/src/main/java/com/dotcms/auth/dotAuth/rest/DotAuthConfigView.java new file mode 100644 index 000000000000..b90502c99f88 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/auth/dotAuth/rest/DotAuthConfigView.java @@ -0,0 +1,62 @@ +package com.dotcms.auth.dotAuth.rest; + +import java.util.Map; + +/** + * Full configuration returned by {@code GET /v1/dotauth/sites/{hostId}}. Hidden + * secrets (e.g. {@code clientSecret}) are masked as {@code "****"}. The {@code + * inherited} flag is set when the requested host has no row of its own but + * SYSTEM_HOST does, in which case {@code values} holds the system defaults so + * the portlet can pre-populate the form when admins break inheritance. + * + *

The {@code protocol} discriminator indicates which authentication protocol + * the stored values correspond to ({@link DotAuthProtocol#OAUTH} or + * {@link DotAuthProtocol#SAML}) and determines the shape of {@code values}. + */ +public class DotAuthConfigView { + + private final String hostId; + private final DotAuthProtocol protocol; + private final boolean configured; + private final boolean inherited; + private final Map values; + private final Map headlessValues; + + public DotAuthConfigView(final String hostId, + final DotAuthProtocol protocol, + final boolean configured, + final boolean inherited, + final Map values, + final Map headlessValues) { + this.hostId = hostId; + this.protocol = protocol; + this.configured = configured; + this.inherited = inherited; + this.values = values; + this.headlessValues = headlessValues; + } + + public String getHostId() { + return hostId; + } + + public DotAuthProtocol getProtocol() { + return protocol; + } + + public boolean isConfigured() { + return configured; + } + + public boolean isInherited() { + return inherited; + } + + public Map getValues() { + return values; + } + + public Map getHeadlessValues() { + return headlessValues; + } +} diff --git a/dotCMS/src/main/java/com/dotcms/auth/dotAuth/rest/DotAuthOAuthExchangeResource.java b/dotCMS/src/main/java/com/dotcms/auth/dotAuth/rest/DotAuthOAuthExchangeResource.java new file mode 100644 index 000000000000..7b95f95a7245 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/auth/dotAuth/rest/DotAuthOAuthExchangeResource.java @@ -0,0 +1,610 @@ +package com.dotcms.auth.dotAuth.rest; + +import com.dotcms.auth.dotAuth.session.DotAuthSessionCache; +import com.dotmarketing.business.CacheLocator; +import com.dotcms.auth.providers.oauth.OAuthAppConfig; +import com.dotcms.auth.providers.oauth.OAuthHelper; +import com.dotcms.auth.providers.oauth.OAuthSsrfGuard; +import com.dotcms.auth.providers.oauth.provider.OIDCProvider; +import com.dotcms.rest.ResponseEntityView; +import com.dotcms.rest.annotation.NoCache; +import com.dotcms.rest.api.v1.DotObjectMapperProvider; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.dotmarketing.business.APILocator; +import com.dotmarketing.business.Role; +import com.dotmarketing.exception.DotDataException; +import com.dotmarketing.exception.DotRuntimeException; +import com.dotmarketing.util.Config; +import com.dotmarketing.util.Logger; +import com.dotmarketing.util.SecurityLogger; +import com.dotmarketing.util.UtilMethods; +import com.liferay.portal.model.User; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import io.vavr.control.Try; +import java.io.Serializable; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.ws.rs.Consumes; +import javax.ws.rs.OPTIONS; +import javax.ws.rs.POST; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; + +/** + * REST endpoint that exchanges a caller-supplied OIDC {@code id_token} for an + * opaque dotAuth session-ref. Designed for headless / SPA consumers + * (e.g. a Next.js front-end) that have already completed an Authorization-Code + * + PKCE flow with an OIDC provider such as Okta and now need a dotCMS-scoped + * credential to call the content APIs with per-user permissions. + * + *

Why a session-ref, not a JWT

+ * + * The earlier iteration of this endpoint minted a dotCMS USER_TOKEN JWT. That + * path requires {@code jwt.jti == user.rememberMeToken}, and {@code + * rememberMeToken} is a transient in-memory field — any user-cache flush + * invalidates every outstanding JWT. For a stateless bearer flow the correct + * analogue is the SAML browser session: ephemeral server-side state, no DB + * persistence, re-auth on loss. That's what the session-ref is. See + * {@link DotAuthSessionCache}. + * + *

Contract

+ * + *
{@code
+ * POST /api/v1/dotauth/oauth/exchange
+ * Content-Type: application/json
+ *
+ * {
+ *   "idToken":        "eyJhbGciOiJSUzI1NiIsImtpZCI6Ii4uLiJ9...",
+ *   "nonce":          "",
+ *   "expirationDays": 7          // optional; clamped to the configured max
+ * }
+ * }
+ * + *

On success the server replies with a session-ref + a minimal user summary: + * + *

{@code
+ * {
+ *   "entity": {
+ *     "sessionRef":     "dsr_",
+ *     "expiresAt":      "2026-04-30T14:22:11Z",
+ *     "expirationDays": 7,
+ *     "user": {
+ *       "userId":    "oauth:OIDC:00u1234...",
+ *       "email":     "user@example.com",
+ *       "firstName": "Scott",
+ *       "lastName":  "Wicken",
+ *       "fullName":  "Scott Wicken",
+ *       "roles":     ["CMS Administrator", "Editor"]
+ *     }
+ *   }
+ * }
+ * }
+ * + *

What gets validated downstream

+ * + * Before a dotCMS user is resolved or a session-ref is issued, the {@code id_token} + * passes the full OIDC validation chain in + * {@link OIDCProvider#validateIdTokenAndExtractClaims}: + *
    + *
  1. JWS signature verified against the IdP's JWKS (discovered via + * {@code jwks_uri} in the OIDC discovery document).
  2. + *
  3. {@code alg} in the header must be in the allow-list (RS256/ES256 family) — + * prevents alg-confusion attacks.
  4. + *
  5. {@code iss} must equal the configured {@code issuerUrl}.
  6. + *
  7. {@code aud} must contain the configured {@code client_id}. + * This is the check that makes the token ours, not just "valid + * somewhere on this IdP" — it's the mitigation for the confused-deputy + * class the old {@code /v1/oauth/token} endpoint shipped with.
  8. + *
  9. {@code exp} must be in the future.
  10. + *
  11. {@code nonce} must equal the {@code nonce} supplied in the request body + * (a consistency check on the caller-presented values). This is not + * replay protection in the headless flow — the caller presents both the + * {@code id_token} and its {@code nonce}. Replay is guarded separately: each + * exchanged {@code id_token} is recorded as one-time-use (a fingerprint cached + * until the token's {@code exp}) and a second exchange of the same token is + * rejected with 401.
  12. + *
+ * + *

Safety notes

+ *
    + *
  • The endpoint is active when the {@code dotauth-headless} App config has + * {@code enabled = true}. No separate feature flag is required.
  • + *
  • Requires the current site's {@code dotAuth} App config to use + * {@code providerType = OIDC}. Plain OAuth2 providers do not issue an + * {@code id_token} that can be validated downstream and are rejected with + * 400.
  • + *
  • Session-refs live only in the in-memory dotCMS cache (cluster-replicated + * via Hazelcast where configured). Loss of the cache entry — node restart, + * eviction, explicit logout — forces the SPA through Okta again. No row + * is ever written to the database for the session itself.
  • + *
+ */ +@Path("/v1/dotauth/oauth") +@Tag(name = "dotAuth", description = "OAuth/OIDC and SAML authentication: per-site configuration (SYSTEM_HOST is the global default) and headless OIDC token exchange") +public class DotAuthOAuthExchangeResource implements Serializable { + + private static final long serialVersionUID = 1L; + + /** Default session lifetime when the caller omits {@code expirationDays}. */ + private static final String DEFAULT_DAYS_PROP = "DOTAUTH_SESSION_DEFAULT_DAYS"; + private static final int DEFAULT_DAYS_FALLBACK = 7; + + /** Hard ceiling on requested session lifetime; any larger request is clamped to this. */ + private static final String MAX_DAYS_PROP = "DOTAUTH_SESSION_MAX_DAYS"; + private static final int MAX_DAYS_FALLBACK = 7; + + private static final ObjectMapper MAPPER = + DotObjectMapperProvider.getInstance().getDefaultObjectMapper(); + + private final OAuthHelper oauthHelper = new OAuthHelper(); + private final DotAuthSessionCache sessionCache = CacheLocator.getDotAuthSessionCache(); + + @OPTIONS + @Path("/exchange") + @NoCache + public Response exchangePreflight(@Context final HttpServletRequest request, + @Context final HttpServletResponse response) { + final Optional cfgOpt = OAuthAppConfig.exchangeConfig(request); + if (cfgOpt.isEmpty()) { + return Response.status(Response.Status.NO_CONTENT).build(); + } + final List origins = parseJsonStringList(cfgOpt.get().allowedOriginsJson); + final String origin = request.getHeader("Origin"); + if (!origins.isEmpty() && UtilMethods.isSet(origin) && origins.contains(origin)) { + response.setHeader("Access-Control-Allow-Origin", origin); + response.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS"); + response.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization"); + response.setHeader("Access-Control-Allow-Credentials", "true"); + response.setHeader("Access-Control-Max-Age", "86400"); + } + return Response.status(Response.Status.NO_CONTENT).build(); + } + + @POST + @Path("/exchange") + @NoCache + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Operation( + summary = "Exchange an OIDC id_token for a dotAuth session-ref", + description = "Validates the caller-supplied id_token against the configured OIDC " + + "provider's JWKS (signature, iss, aud == our client_id, exp, nonce), " + + "resolves or JIT-provisions the matching dotCMS user, and returns an " + + "opaque session-ref bound to that user.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Token exchange succeeded", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ResponseEntityOAuthExchangeView.class))), + @ApiResponse(responseCode = "400", description = "Malformed payload or non-OIDC provider configured"), + @ApiResponse(responseCode = "401", description = "id_token failed validation (signature, iss, aud, exp, or nonce)"), + @ApiResponse(responseCode = "403", description = "Resolved dotCMS user exists but is not active"), + @ApiResponse(responseCode = "404", description = "Exchange endpoint not found"), + @ApiResponse(responseCode = "503", description = "OAuth is not configured for this site") + }) + public Response exchange(@Context final HttpServletRequest request, + @Context final HttpServletResponse response, + final OAuthExchangeForm form) { + try { + if (form == null + || !UtilMethods.isSet(form.getIdToken()) + || !UtilMethods.isSet(form.getNonce())) { + return Response.status(Response.Status.BAD_REQUEST) + .entity(new ResponseEntityView<>("idToken and nonce are both required")) + .build(); + } + + // Site-scoped exchange config. Falls back to SYSTEM_HOST per the dotAuth contract, + // then falls back from exchange-specific keys to the browser-login keys for + // pre-split configurations. + final Optional cfgOpt = OAuthAppConfig.exchangeConfig(request); + if (cfgOpt.isEmpty()) { + return Response.status(Response.Status.SERVICE_UNAVAILABLE) + .entity(new ResponseEntityView<>("OAuth is not configured for this site")) + .build(); + } + final OAuthAppConfig config = cfgOpt.get(); + + // CORS origin check: reject requests from unlisted browser origins. + // When no allowedOrigins are configured, browser-originated requests are + // denied outright — the safe default for a token-exchange endpoint. + // Non-browser callers (no Origin header) pass through unaffected. + final List allowedOrigins = parseJsonStringList(config.allowedOriginsJson); + final String origin = request.getHeader("Origin"); + if (UtilMethods.isSet(origin)) { + if (allowedOrigins.isEmpty()) { + SecurityLogger.logInfo(DotAuthOAuthExchangeResource.class, + "OAuth exchange rejected: no allowedOrigins configured; origin '" + + sanitizeForLog(origin) + "' from " + request.getRemoteAddr()); + return Response.status(Response.Status.FORBIDDEN) + .entity(new ResponseEntityView<>( + "allowedOrigins must be configured for browser-based token exchange")) + .build(); + } + if (!allowedOrigins.contains(origin)) { + SecurityLogger.logInfo(DotAuthOAuthExchangeResource.class, + "OAuth exchange rejected: origin '" + sanitizeForLog(origin) + + "' not in allowed origins from " + request.getRemoteAddr()); + return Response.status(Response.Status.FORBIDDEN) + .entity(new ResponseEntityView<>("Origin not allowed")) + .build(); + } + response.setHeader("Access-Control-Allow-Origin", origin); + response.setHeader("Access-Control-Allow-Credentials", "true"); + } + + if (!config.isOidc()) { + // Plain OAuth2 has no id_token to validate downstream. Tell the client why + // explicitly rather than pretend-validating something we can't actually check. + SecurityLogger.logInfo(DotAuthOAuthExchangeResource.class, + "Refused OAuth exchange: site is configured for non-OIDC provider '" + + config.providerType + "' from " + request.getRemoteAddr()); + return Response.status(Response.Status.BAD_REQUEST) + .entity(new ResponseEntityView<>( + "OIDC is required for token exchange. Site is configured for " + + "'" + config.providerType + "'. Only providers that issue " + + "an id_token (signed JWT) can be validated by this endpoint.")) + .build(); + } + + // Trusted IdP validation: if trusted IdPs are configured, decode the JWT's + // iss claim (unverified) and match it against the allowlist. Use the matched + // IdP's JWKS/audience/issuer for verification instead of the site-level config. + final List> trustedIdps = parseJsonMapList(config.trustedIdpsJson); + String effectiveIssuer = config.issuerUrl; + String effectiveClientId = config.clientId; + char[] effectiveSecret = config.clientSecret; + String effectiveGroupsClaim = config.groupsClaim; + String effectiveGroupsUrl = config.groupsUrl; + Map matchedIdp = null; + + if (!trustedIdps.isEmpty()) { + final String tokenIssuer = extractUnverifiedIssuer(form.getIdToken()); + if (tokenIssuer == null) { + return Response.status(Response.Status.BAD_REQUEST) + .entity(new ResponseEntityView<>("Could not decode iss claim from id_token")) + .build(); + } + final Optional> matched = trustedIdps.stream() + .filter(idp -> Boolean.TRUE.equals(idp.get("enabled")) + || "true".equals(String.valueOf(idp.get("enabled")))) + .filter(idp -> tokenIssuer.equals(String.valueOf(idp.get("issuer")))) + .findFirst(); + if (matched.isEmpty()) { + SecurityLogger.logInfo(DotAuthOAuthExchangeResource.class, + "OAuth exchange rejected: issuer '" + sanitizeForLog(tokenIssuer) + + "' not in trusted IdP list from " + request.getRemoteAddr()); + return Response.status(Response.Status.UNAUTHORIZED) + .entity(new ResponseEntityView<>("Untrusted token issuer")) + .build(); + } + final Map idp = matched.get(); + matchedIdp = idp; + effectiveIssuer = String.valueOf(idp.getOrDefault("issuer", effectiveIssuer)); + effectiveGroupsClaim = String.valueOf(idp.getOrDefault("claimGroups", effectiveGroupsClaim)); + final String idpAudience = String.valueOf(idp.getOrDefault("audience", "")); + if (UtilMethods.isSet(idpAudience)) { + effectiveClientId = idpAudience; + } + } + + // Validate the effective issuer URL before discovery fetches it. + // Top-level config.issuerUrl goes through OAuthAppConfig.validateUrl(); + // trusted IdP issuers from raw JSON must be checked here. + if (!validateIssuerUrl(effectiveIssuer)) { + SecurityLogger.logInfo(DotAuthOAuthExchangeResource.class, + "OAuth exchange rejected: issuer URL failed validation from " + + request.getRemoteAddr()); + return Response.status(Response.Status.BAD_REQUEST) + .entity(new ResponseEntityView<>("Issuer URL failed security validation")) + .build(); + } + + final OIDCProvider provider = new OIDCProvider( + effectiveIssuer, effectiveClientId, effectiveSecret, + effectiveGroupsClaim, effectiveGroupsUrl); + + // Core security step: signature-verify against JWKS, check iss/aud/exp/nonce. + final Map claims; + try { + claims = provider.validateIdTokenAndExtractClaims(form.getIdToken(), form.getNonce()); + } catch (final DotRuntimeException e) { + SecurityLogger.logInfo(DotAuthOAuthExchangeResource.class, + "OAuth id_token validation failed from " + request.getRemoteAddr() + + ": " + sanitizeForLog(e.getMessage())); + return Response.status(Response.Status.UNAUTHORIZED) + .entity(new ResponseEntityView<>("id_token validation failed")) + .build(); + } + + // Replay protection: an id_token is single-use for the exchange. A leaked but + // still-unexpired token (browser history, server logs, a malicious RP that shares + // the IdP) must not be exchangeable a second time. The nonce check above is NOT + // replay protection here — the caller presents both the token and the nonce — so + // we fingerprint each consumed token and keep it until its own exp, rejecting any + // re-presentation in that window. + final Long idpExpMillis = extractExpiryMillis(claims.get("exp")); + final long replayGuardExpiry = idpExpMillis != null + ? idpExpMillis + : System.currentTimeMillis() + + MAX_DAYS_FALLBACK * ChronoUnit.DAYS.getDuration().toMillis(); + final String tokenFingerprint = idTokenFingerprint(form.getIdToken()); + if (!sessionCache.registerExchangeTokenUse(tokenFingerprint, replayGuardExpiry)) { + SecurityLogger.logInfo(DotAuthOAuthExchangeResource.class, + "OAuth exchange rejected: id_token already consumed (replay) from " + + request.getRemoteAddr()); + return Response.status(Response.Status.UNAUTHORIZED) + .entity(new ResponseEntityView<>("id_token has already been exchanged")) + .build(); + } + + // Build effective config: when a trusted IdP matched, overlay its per-IdP + // claim mappings, role behavior, and provisioning settings onto the base config. + final OAuthAppConfig effectiveConfig; + if (matchedIdp != null) { + normalizeIdpMapValues(matchedIdp); + effectiveConfig = config.withTrustedIdpOverrides(matchedIdp); + } else { + effectiveConfig = config; + } + + final User user; + try { + // The userinfo map here IS the signature-verified id_token claim set, so it + // doubles as the authoritative source for the email_verified trust decision. + user = oauthHelper.resolveOrProvisionUser(provider, null, claims, claims, effectiveConfig, /* frontEndLogin */ true); + } catch (final DotRuntimeException e) { + if (e.getMessage() != null + && (e.getMessage().contains("is not active") + || e.getMessage().contains("Auto-provisioning is disabled"))) { + return Response.status(Response.Status.FORBIDDEN) + .entity(new ResponseEntityView<>(e.getMessage())).build(); + } + throw e; + } catch (final DotDataException e) { + // Checked exception from the user/role APIs — typically a DB / data-layer + // failure during JIT provisioning. Do not leak the underlying message (it + // can include SQL state, table / column names) onto the wire. Log server-side + // with the throwable so ops gets the full stack, return a fixed-string 500. + Logger.error(DotAuthOAuthExchangeResource.class, + "OAuth exchange user provisioning failed for request from " + request.getRemoteAddr(), e); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(new ResponseEntityView<>("User provisioning failed")) + .build(); + } + + // Session lifetime: prefer per-site config (minutes), fall back to env-var (days) + long lifetimeMillis; + final int configTtlMinutes = config.sessionRefTtlMinutes; + if (configTtlMinutes > 0) { + lifetimeMillis = configTtlMinutes * 60_000L; + } else { + final int expirationDays = clampExpirationDays(form.getExpirationDays()); + lifetimeMillis = ChronoUnit.DAYS.getDuration().toMillis() * expirationDays; + } + + // Clamp to IdP token exp so the session-ref never outlives the JWT it was minted from. + // Nimbus surfaces exp as a java.util.Date (not a Number) via JWTClaimsSet.getClaims(), + // so extractExpiryMillis handles both forms — an `instanceof Number` test alone would + // never fire and this defaults-on control would be dead code. + if (config.clampToIdpExp && idpExpMillis != null) { + final long idpRemainingMillis = idpExpMillis - System.currentTimeMillis(); + if (idpRemainingMillis > 0 && idpRemainingMillis < lifetimeMillis) { + lifetimeMillis = idpRemainingMillis; + } + } + + final String sessionRef = sessionCache.create(user.getUserId(), lifetimeMillis); + final String expiresAt = Instant.now().plusMillis(lifetimeMillis).toString(); + final int expirationDays = (int) Math.ceil(lifetimeMillis / (double) ChronoUnit.DAYS.getDuration().toMillis()); + + SecurityLogger.logInfo(DotAuthOAuthExchangeResource.class, + "OAuth exchange issued session-ref for user " + user.getUserId() + + " (" + user.getEmailAddress() + ") from " + request.getRemoteAddr()); + + final OAuthExchangeView.UserSummary summary = new OAuthExchangeView.UserSummary( + user.getUserId(), + user.getEmailAddress(), + user.getFirstName(), + user.getLastName(), + user.getFullName(), + loadRoleKeys(user)); + + return Response.ok(new ResponseEntityOAuthExchangeView( + new OAuthExchangeView(sessionRef, expiresAt, expirationDays, summary))).build(); + } catch (final Exception e) { + // Fixed-string 500 on the wire — the exception's message can include + // low-level detail (SQL state, table / column names, stack context) that + // the SPA has no legitimate need for. Log server-side with the throwable + // so ops gets the full stack; Logger.warn(message) alone would have + // silently dropped it. + Logger.error(DotAuthOAuthExchangeResource.class, + "OAuth exchange failed for request from " + request.getRemoteAddr(), e); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(new ResponseEntityView<>("Token exchange failed")) + .build(); + } + } + + /** + * Load the applied role keys for this user so the SPA can render role-gated UI + * without a second round-trip. Returns an empty list if role resolution fails — + * the SPA can still proceed, it just won't have role-aware UI hints. + */ + private List loadRoleKeys(final User user) { + return Try.of(() -> APILocator.getRoleAPI().loadRolesForUser(user.getUserId())) + .getOrElse(Collections.emptyList()) + .stream() + .map(Role::getRoleKey) + .filter(UtilMethods::isSet) + .collect(Collectors.toList()); + } + + @SuppressWarnings("unchecked") + private static List parseJsonStringList(final String json) { + if (!UtilMethods.isSet(json) || "[]".equals(json.trim())) { + return Collections.emptyList(); + } + try { + return (List) MAPPER.readValue(json, List.class); + } catch (final Exception e) { + Logger.warn(DotAuthOAuthExchangeResource.class, + "Failed to parse allowedOrigins config: " + e.getMessage()); + return Collections.emptyList(); + } + } + + @SuppressWarnings("unchecked") + private static List> parseJsonMapList(final String json) { + if (!UtilMethods.isSet(json) || "[]".equals(json.trim())) { + return Collections.emptyList(); + } + try { + return (List>) MAPPER.readValue(json, + new TypeReference>>() {}); + } catch (final Exception e) { + Logger.warn(DotAuthOAuthExchangeResource.class, + "Failed to parse trustedIdps config: " + e.getMessage()); + return Collections.emptyList(); + } + } + + private static String extractUnverifiedIssuer(final String idToken) { + try { + final String[] parts = idToken.split("\\."); + if (parts.length < 2) return null; + final String payload = new String(java.util.Base64.getUrlDecoder().decode(parts[1])); + final com.fasterxml.jackson.databind.JsonNode node = MAPPER.readTree(payload); + return node.has("iss") ? node.get("iss").asText() : null; + } catch (final Exception e) { + Logger.debug(DotAuthOAuthExchangeResource.class, "Failed to extract iss from id_token", e); + return null; + } + } + + private static void normalizeIdpMapValues(final Map idp) { + final Object gm = idp.get("groupMappings"); + if (gm != null && !(gm instanceof String)) { + idp.put("groupMappings", Try.of(() -> MAPPER.writeValueAsString(gm)).getOrElse("[]")); + } + final Object dr = idp.get("defaultRoles"); + if (dr instanceof List) { + idp.put("defaultRoles", ((List) dr).stream() + .map(String::valueOf).filter(UtilMethods::isSet) + .collect(Collectors.joining(","))); + } + final Object rb = idp.get("roleBehavior"); + if (rb instanceof String) { + idp.put("roleBehavior", translateRoleBehavior((String) rb)); + } + } + + private static String translateRoleBehavior(final String uiValue) { + switch (uiValue) { + case "sync-all": return "ALL"; + case "idp-only": return "IDP"; + case "static-only": return "STATICONLY"; + case "additive": return "STATICADD"; + case "none": return "NONE"; + default: return uiValue; + } + } + + private static boolean validateIssuerUrl(final String url) { + return OAuthSsrfGuard.validateUrl(url) == null; + } + + /** + * Extract the id_token {@code exp} as epoch-millis. Nimbus surfaces registered date + * claims ({@code exp}/{@code nbf}/{@code iat}) as {@link java.util.Date} via + * {@code JWTClaimsSet.getClaims()}; a raw NumericDate ({@link Number} epoch-seconds) is + * also handled defensively. Returns {@code null} when no usable exp is present. + */ + private static Long extractExpiryMillis(final Object expClaim) { + if (expClaim instanceof java.util.Date) { + return ((java.util.Date) expClaim).getTime(); + } + if (expClaim instanceof Number) { + return ((Number) expClaim).longValue() * 1000L; + } + return null; + } + + /** + * Lowercase-hex SHA-256 of the id_token, used as the one-time-use replay key. Hashing + * keeps the raw bearer token out of cache keys and yields a case-insensitive key that + * survives the cache administrator's key normalization without collision risk. + * Fails closed on the JVM-impossible {@code NoSuchAlgorithmException} rather than + * skipping the replay check — matches {@code OAuthCrypto.pkceChallengeS256}. + */ + private static String idTokenFingerprint(final String idToken) { + try { + final java.security.MessageDigest md = java.security.MessageDigest.getInstance("SHA-256"); + final byte[] hash = md.digest(idToken.getBytes(StandardCharsets.UTF_8)); + final StringBuilder sb = new StringBuilder(hash.length * 2); + for (final byte b : hash) { + sb.append(Character.forDigit((b >> 4) & 0xF, 16)) + .append(Character.forDigit(b & 0xF, 16)); + } + return sb.toString(); + } catch (final java.security.NoSuchAlgorithmException e) { + // SHA-256 is JVM-mandatory; fail closed rather than skip replay protection. + throw new DotRuntimeException("SHA-256 unavailable for id_token replay fingerprint", e); + } + } + + /** + * Strip CR/LF/tab and cap length on values that originate from request headers or + * unverified token claims before writing them to the security log — prevents + * log-forging / audit-trail injection (CWE-117). + */ + private static String sanitizeForLog(final String value) { + if (value == null) { + return ""; + } + final String stripped = value.replaceAll("[\\r\\n\\t]", "_"); + return stripped.length() > 256 ? stripped.substring(0, 256) + "..." : stripped; + } + + /** + * Clamp the requested session lifetime to the configured default and max. The + * defaults (7 / 7) match the FE team's NextAuth session {@code maxAge} so the + * two session clocks stay aligned without per-environment tuning. + * + *

Guards against three misconfig foot-guns: + *

    + *
  • A non-positive {@code DOTAUTH_SESSION_DEFAULT_DAYS} falls back to the + * hard-coded default — otherwise a caller that omits {@code expirationDays} + * would receive a 0-day session (dead on arrival).
  • + *
  • A non-positive {@code DOTAUTH_SESSION_MAX_DAYS} falls back to the + * hard-coded max — otherwise the ceiling is effectively disabled and any + * caller-supplied value is honored up to {@link Integer#MAX_VALUE}.
  • + *
  • The result is floored at 1 day so we never hand back a session whose + * expiry is already in the past.
  • + *
+ */ + private int clampExpirationDays(final int requested) { + final int rawDefault = Config.getIntProperty(DEFAULT_DAYS_PROP, DEFAULT_DAYS_FALLBACK); + final int rawMax = Config.getIntProperty(MAX_DAYS_PROP, MAX_DAYS_FALLBACK); + final int defaultDays = rawDefault > 0 ? rawDefault : DEFAULT_DAYS_FALLBACK; + final int maxAllowed = rawMax > 0 ? rawMax : MAX_DAYS_FALLBACK; + final int effective = requested <= 0 ? defaultDays : requested; + return Math.max(1, Math.min(effective, maxAllowed)); + } +} diff --git a/dotCMS/src/main/java/com/dotcms/auth/dotAuth/rest/DotAuthProtocol.java b/dotCMS/src/main/java/com/dotcms/auth/dotAuth/rest/DotAuthProtocol.java new file mode 100644 index 000000000000..121c2bc6de26 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/auth/dotAuth/rest/DotAuthProtocol.java @@ -0,0 +1,14 @@ +package com.dotcms.auth.dotAuth.rest; + +/** + * Authentication protocol handled by the dotAuth portlet. Used as the + * discriminator on {@link DotAuthConfigView} and {@link DotAuthConfigForm}. + */ +public enum DotAuthProtocol { + + /** OAuth 2.0 / OIDC — secrets stored under the {@code dotAuth} app key. */ + OAUTH, + + /** SAML 2.0 — secrets stored under the {@code dotsaml-config} app key. */ + SAML +} diff --git a/dotCMS/src/main/java/com/dotcms/auth/dotAuth/rest/DotAuthResource.java b/dotCMS/src/main/java/com/dotcms/auth/dotAuth/rest/DotAuthResource.java new file mode 100644 index 000000000000..a9bd86b17012 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/auth/dotAuth/rest/DotAuthResource.java @@ -0,0 +1,1023 @@ +package com.dotcms.auth.dotAuth.rest; + +import static com.dotcms.rest.ResponseEntityView.OK; + +import com.dotcms.auth.dotAuth.session.DotAuthSessionCache; +import com.dotcms.http.CircuitBreakerUrl; +import com.dotmarketing.business.CacheLocator; +import com.google.common.collect.ImmutableMap; +import com.dotcms.rest.api.v1.DotObjectMapperProvider; +import com.dotcms.auth.dotAuth.DotAuthConstants; +import com.dotcms.auth.dotAuth.rest.handler.HeadlessConfigHelper; +import com.dotcms.auth.dotAuth.rest.handler.OAuthProtocolHandler; +import com.dotcms.auth.dotAuth.rest.handler.ProtocolHandler; +import com.dotcms.auth.dotAuth.rest.handler.SamlProtocolHandler; +import com.dotcms.auth.providers.oauth.OAuthSsrfGuard; +import com.dotcms.rest.InitDataObject; +import com.dotcms.rest.ResponseEntityStringView; +import com.dotcms.rest.ResponseEntityView; +import com.dotcms.rest.WebResource; +import com.dotcms.rest.annotation.NoCache; +import com.dotcms.rest.api.MultiPartUtils; +import com.dotcms.rest.api.v1.authentication.ResponseUtil; +import com.dotcms.security.apps.AppSecrets; +import com.dotcms.security.apps.AppsAPI; +import com.dotcms.security.apps.AppsUtil; +import com.dotcms.security.apps.Secret; +import com.dotcms.security.apps.Type; +import com.dotmarketing.beans.Host; +import com.dotmarketing.business.APILocator; +import com.dotmarketing.exception.DoesNotExistException; +import com.dotmarketing.exception.DotDataException; +import com.dotmarketing.exception.DotSecurityException; +import com.dotmarketing.util.Logger; +import com.dotmarketing.util.PortletID; +import com.dotmarketing.util.SecurityLogger; +import com.dotmarketing.util.UtilMethods; +import com.fasterxml.jackson.jaxrs.json.annotation.JSONP; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.annotations.VisibleForTesting; +import com.liferay.portal.model.User; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.io.File; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.security.Key; +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.io.IOException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.ws.rs.BadRequestException; +import javax.ws.rs.Consumes; +import javax.ws.rs.DELETE; +import javax.ws.rs.GET; +import javax.ws.rs.POST; +import javax.ws.rs.PUT; +import javax.ws.rs.core.HttpHeaders; +import javax.ws.rs.Path; +import javax.ws.rs.PathParam; +import javax.ws.rs.Produces; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import javax.ws.rs.core.StreamingOutput; +import org.glassfish.jersey.media.multipart.FormDataMultiPart; + +/** + * REST surface for the dotAuth portlet. Edits and reads the {@code dotAuth} + * (OAuth) and {@code dotsaml-config} (SAML) AppSecrets rows on a per-site + * basis, with the SYSTEM_HOST row exposed as the global default via the + * sentinel {@code "SYSTEM_HOST"} host id. + *

+ * Protocol dispatch is delegated to a {@link ProtocolHandler} per protocol. + * Saves are mutually exclusive: writing one protocol's secrets deletes the + * other protocol's row for the same host. + *

+ * All endpoints delegate straight to {@link AppsAPI}; there is no separate + * persistence layer. + */ +@Path("/v1/dotauth") +@Tag(name = "dotAuth", description = "OAuth/OIDC and SAML authentication: per-site configuration (SYSTEM_HOST is the global default) and headless OIDC token exchange") +public class DotAuthResource { + + /** Sentinel path value representing the SYSTEM_HOST row (the global default). */ + public static final String SYSTEM_HOST_SENTINEL = "SYSTEM_HOST"; + private static final int OIDC_DISCOVERY_MAX_RESPONSE_BYTES = 1024 * 1024; + private static final int SAML_METADATA_MAX_RESPONSE_BYTES = 5 * 1024 * 1024; + + private final WebResource webResource; + private final AppsAPI appsAPI; + private final Map handlers; + private final HeadlessConfigHelper headlessHelper; + private final DotAuthSessionCache sessionCache = CacheLocator.getDotAuthSessionCache(); + private static final ObjectMapper MAPPER = + DotObjectMapperProvider.getInstance().getDefaultObjectMapper(); + private static final int EXPORT_PASSWORD_MIN_LENGTH = 14; + private static final int EXPORT_PASSWORD_MAX_LENGTH = 32; + private static final Set DOTAUTH_APP_KEYS = Set.of( + DotAuthConstants.APP_KEY, + com.dotcms.saml.DotSamlProxyFactory.SAML_APP_CONFIG_KEY, + DotAuthConstants.HEADLESS_APP_KEY); + + public DotAuthResource() { + this(new WebResource(), APILocator.getAppsAPI(), defaultHandlers(), + new HeadlessConfigHelper()); + } + + @VisibleForTesting + public DotAuthResource(final WebResource webResource, + final AppsAPI appsAPI, + final Map handlers, + final HeadlessConfigHelper headlessHelper) { + this.webResource = webResource; + this.appsAPI = appsAPI; + this.handlers = handlers; + this.headlessHelper = headlessHelper; + } + + private static Map defaultHandlers() { + final Map map = new EnumMap<>(DotAuthProtocol.class); + map.put(DotAuthProtocol.OAUTH, new OAuthProtocolHandler()); + map.put(DotAuthProtocol.SAML, new SamlProtocolHandler()); + return map; + } + + @GET + @Path("/sites") + @JSONP + @NoCache + @Produces({MediaType.APPLICATION_JSON, "application/javascript"}) + @Operation(operationId = "listDotAuthSites", + summary = "List all sites with their dotAuth status", + description = "Returns the SYSTEM_HOST (global default) status plus a per-site list " + + "indicating whether each site has its own OAuth/SAML configuration, inherits " + + "from the system default, or is unconfigured.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Sites retrieved successfully", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ResponseEntityDotAuthSitesView.class))), + @ApiResponse(responseCode = "401", description = "Authentication required"), + @ApiResponse(responseCode = "403", description = "User does not have permission to access dotAuth"), + @ApiResponse(responseCode = "500", description = "Internal server error") + }) + public final Response listSites(@Context final HttpServletRequest request, + @Context final HttpServletResponse response) { + try { + final User user = initUser(request, response); + final Host systemHost = APILocator.systemHost(); + final Map> appsByHost = appsAPI.appKeysByHost(); + // AppsUtil.internalKey lowercases both the host id AND the app key when storing, + // so everything in `appsByHost` is lowercase. Compare against lowercased values. + final Set systemKeys = appsByHost + .getOrDefault(systemHost.getIdentifier().toLowerCase(), Set.of()); + final Optional systemProtocol = detectProtocol(systemKeys); + final boolean systemHeadless = systemKeys.contains( + DotAuthConstants.HEADLESS_APP_KEY.toLowerCase()); + + // Use the paginated search API instead of the deprecated findAll. + // An empty filter with limit <= 0 returns all live, non-system hosts. + final List allHosts = APILocator.getHostAPI() + .search("", false, false, 0, 0, user, false); + final List rows = new ArrayList<>(); + for (final Host host : allHosts) { + final Set hostKeys = appsByHost + .getOrDefault(host.getIdentifier().toLowerCase(), Set.of()); + final Optional hostProtocol = detectProtocol(hostKeys); + + final DotAuthSiteStatus status; + final DotAuthProtocol rowProtocol; + if (hostProtocol.isPresent()) { + status = DotAuthSiteStatus.SITE_OVERRIDE; + rowProtocol = hostProtocol.get(); + } else if (systemProtocol.isPresent()) { + status = DotAuthSiteStatus.INHERITED; + rowProtocol = systemProtocol.get(); + } else { + status = DotAuthSiteStatus.NOT_CONFIGURED; + rowProtocol = null; + } + rows.add(new DotAuthSitesView.SiteRowView( + host.getIdentifier(), host.getHostname(), status, rowProtocol)); + } + + final DotAuthSitesView entity = new DotAuthSitesView( + new DotAuthSitesView.SystemView(systemProtocol.isPresent(), + systemProtocol.orElse(null), systemHeadless), + rows); + return Response.ok(new ResponseEntityDotAuthSitesView(entity)).build(); + } catch (final Exception e) { + Logger.error(this.getClass(), "Error listing dotAuth sites", e); + return ResponseUtil.mapExceptionResponse(e); + } + } + + @GET + @Path("/sites/{hostId}") + @JSONP + @NoCache + @Produces({MediaType.APPLICATION_JSON, "application/javascript"}) + @Operation(operationId = "getDotAuthConfig", + summary = "Get the dotAuth configuration for a site", + description = "Returns the protocol-specific configuration stored for the given hostId. " + + "Use the sentinel \"SYSTEM_HOST\" for the global default. When the host has no row " + + "of its own and the system default is configured, the response carries " + + "inherited=true and values holds the system defaults. Hidden secrets (e.g. " + + "clientSecret, privateKey) are masked as \"****\".") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Configuration retrieved successfully", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ResponseEntityDotAuthConfigView.class))), + @ApiResponse(responseCode = "401", description = "Authentication required"), + @ApiResponse(responseCode = "403", description = "User does not have permission to read this site"), + @ApiResponse(responseCode = "404", description = "Site not found"), + @ApiResponse(responseCode = "500", description = "Internal server error") + }) + public final Response getConfig(@Context final HttpServletRequest request, + @Context final HttpServletResponse response, + @PathParam("hostId") final String hostId) { + try { + final User user = initUser(request, response); + final Host host = resolveHost(hostId, user); + + // --- SSO config --- + DotAuthProtocol ssoProtocol = DotAuthProtocol.OAUTH; + boolean ssoConfigured = false; + boolean ssoInherited = false; + Map ssoValues = Map.of(); + + final Optional ownConfig = findConfiguredProtocol(host, user, false); + if (ownConfig.isPresent()) { + final ProtocolConfig config = ownConfig.get(); + ssoProtocol = config.protocol; + ssoConfigured = true; + ssoValues = config.values; + } + + if (!ssoConfigured && !host.isSystemHost()) { + final Optional inheritedConfig = + findConfiguredProtocol(host, user, true); + if (inheritedConfig.isPresent()) { + final ProtocolConfig config = inheritedConfig.get(); + ssoProtocol = config.protocol; + ssoInherited = true; + ssoValues = config.values; + } + } + + // --- Headless config (system-only, no per-site) --- + final Host systemHost = APILocator.systemHost(); + final Map headlessValues = appsAPI.getSecrets( + DotAuthConstants.HEADLESS_APP_KEY, false, systemHost, user) + .map(headlessHelper::values).orElse(Map.of()); + + return Response.ok(new ResponseEntityDotAuthConfigView( + new DotAuthConfigView(hostId, ssoProtocol, ssoConfigured, ssoInherited, + ssoValues, headlessValues))).build(); + } catch (final Exception e) { + Logger.error(this.getClass(), + String.format("Error loading dotAuth config for hostId `%s`", hostId), e); + return ResponseUtil.mapExceptionResponse(e); + } + } + + @PUT + @Path("/sites/{hostId}") + @JSONP + @NoCache + @Consumes(MediaType.APPLICATION_JSON) + @Produces({MediaType.APPLICATION_JSON, "application/javascript"}) + @Operation(operationId = "saveDotAuthConfig", + summary = "Save (upsert) the dotAuth configuration for a site", + description = "Writes the chosen protocol's secrets for the given hostId and deletes " + + "the other protocol's row for that host (mutual exclusion). Posting \"****\" on " + + "a hidden field preserves the stored value.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Configuration saved successfully", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ResponseEntityStringView.class))), + @ApiResponse(responseCode = "400", description = "Invalid form payload"), + @ApiResponse(responseCode = "401", description = "Authentication required"), + @ApiResponse(responseCode = "403", description = "User does not have permission to edit this site"), + @ApiResponse(responseCode = "404", description = "Site not found"), + @ApiResponse(responseCode = "500", description = "Internal server error") + }) + public final Response saveConfig(@Context final HttpServletRequest request, + @Context final HttpServletResponse response, + @PathParam("hostId") final String hostId, + final DotAuthConfigForm form) { + try { + if (form == null) { + throw new IllegalArgumentException("Body required"); + } + final User user = initUser(request, response); + form.checkValid(); + final Host host = resolveHost(hostId, user); + + // DotAuthConfigForm#constructor already defaults protocol to OAUTH when null, + // so no extra fallback is needed here. + final DotAuthProtocol chosen = form.getProtocol(); + final ProtocolHandler active = handlers.get(chosen); + if (active == null) { + // Jackson should have caught an invalid enum value before we get here, but guard + // anyway: surface as 400 rather than NPE-ing inside buildSecrets below. + throw new BadRequestException("Unknown dotAuth protocol: " + chosen); + } + + // Mutual exclusion between protocols on the same host. AppsAPI is not + // transactional, so order matters on failure: + // 1. Save the chosen protocol FIRST. If validation/IO blows up here, the + // caller still has the previous config intact — no data loss. + // 2. Only once the save succeeds, delete the other protocol's row. A + // failure in step 2 leaves both rows briefly; getConfig prefers the + // row with the newest dotAuth save marker, so the user still sees the + // freshly saved config and can retry the clean-up by re-saving. + final Optional existing = secretsToPreserve(active, host, user); + + appsAPI.saveSecrets(withLastSavedMarker( + active.buildSecrets(form.getValues(), existing)), host, user); + + for (final ProtocolHandler handler : handlers.values()) { + if (handler.protocol() != chosen) { + try { + appsAPI.deleteSecrets(handler.appKey(), host, user); + } catch (final Exception cleanupError) { + Logger.warn(this.getClass(), String.format( + "dotAuth save succeeded but clean-up of stale %s row for host %s failed: %s", + handler.protocol(), hostId, cleanupError.getMessage())); + } + } + } + SecurityLogger.logInfo(DotAuthResource.class, + String.format("User %s saved dotAuth %s config for host %s", + user.getUserId(), chosen, hostId)); + return Response.ok(new ResponseEntityStringView(OK)).build(); + } catch (final Exception e) { + Logger.error(this.getClass(), + String.format("Error saving dotAuth config for hostId `%s`", hostId), e); + return ResponseUtil.mapExceptionResponse(e); + } + } + + @DELETE + @Path("/sites/{hostId}") + @JSONP + @NoCache + @Produces({MediaType.APPLICATION_JSON, "application/javascript"}) + @Operation(operationId = "clearDotAuthConfig", + summary = "Clear the dotAuth configuration for a site", + description = "Deletes both OAuth and SAML secret rows for the given hostId. On " + + "SYSTEM_HOST this removes the global default; non-system hosts fall back to " + + "inheriting from SYSTEM_HOST afterward.") + @ApiResponses(value = { + @ApiResponse(responseCode = "204", description = "Configuration cleared successfully"), + @ApiResponse(responseCode = "401", description = "Authentication required"), + @ApiResponse(responseCode = "403", description = "User does not have permission to edit this site"), + @ApiResponse(responseCode = "404", description = "Site not found"), + @ApiResponse(responseCode = "500", description = "Internal server error") + }) + public final Response clearConfig(@Context final HttpServletRequest request, + @Context final HttpServletResponse response, + @PathParam("hostId") final String hostId) { + try { + final User user = initUser(request, response); + final Host host = resolveHost(hostId, user); + for (final ProtocolHandler handler : handlers.values()) { + appsAPI.deleteSecrets(handler.appKey(), host, user); + } + SecurityLogger.logInfo(DotAuthResource.class, + String.format("User %s cleared dotAuth config for host %s", + user.getUserId(), hostId)); + return Response.noContent().build(); + } catch (final Exception e) { + Logger.error(this.getClass(), + String.format("Error clearing dotAuth config for hostId `%s`", hostId), e); + return ResponseUtil.mapExceptionResponse(e); + } + } + + @PUT + @Path("/headless") + @JSONP + @NoCache + @Consumes(MediaType.APPLICATION_JSON) + @Produces({MediaType.APPLICATION_JSON, "application/javascript"}) + @Operation(operationId = "saveDotAuthHeadlessConfig", + summary = "Save the system-level headless token-exchange configuration", + description = "Writes headless config to SYSTEM_HOST. Headless config is system-wide " + + "(not per-site). SSO saves/deletes never affect it.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Headless config saved"), + @ApiResponse(responseCode = "401", description = "Authentication required"), + @ApiResponse(responseCode = "403", description = "Insufficient permissions"), + @ApiResponse(responseCode = "500", description = "Internal server error") + }) + public final Response saveHeadlessConfig(@Context final HttpServletRequest request, + @Context final HttpServletResponse response, + final Map form) { + try { + if (form == null || form.isEmpty()) { + throw new IllegalArgumentException("Body required"); + } + final User user = initUser(request, response); + requireAdmin(user); + final Host systemHost = APILocator.systemHost(); + appsAPI.saveSecrets(headlessHelper.buildSecrets(form), systemHost, user); + SecurityLogger.logInfo(DotAuthResource.class, + String.format("User %s saved headless config", user.getUserId())); + return Response.ok(new ResponseEntityStringView(OK)).build(); + } catch (final Exception e) { + Logger.error(this.getClass(), "Error saving headless config", e); + return ResponseUtil.mapExceptionResponse(e); + } + } + + @DELETE + @Path("/headless") + @JSONP + @NoCache + @Produces({MediaType.APPLICATION_JSON, "application/javascript"}) + @Operation(operationId = "clearDotAuthHeadlessConfig", + summary = "Clear the system-level headless token-exchange configuration", + description = "Deletes the headless config. SSO config is not affected.") + @ApiResponses(value = { + @ApiResponse(responseCode = "204", description = "Headless config cleared"), + @ApiResponse(responseCode = "401", description = "Authentication required"), + @ApiResponse(responseCode = "403", description = "Insufficient permissions"), + @ApiResponse(responseCode = "500", description = "Internal server error") + }) + public final Response clearHeadlessConfig(@Context final HttpServletRequest request, + @Context final HttpServletResponse response) { + try { + final User user = initUser(request, response); + requireAdmin(user); + final Host systemHost = APILocator.systemHost(); + appsAPI.deleteSecrets(DotAuthConstants.HEADLESS_APP_KEY, systemHost, user); + SecurityLogger.logInfo(DotAuthResource.class, + String.format("User %s cleared headless config", user.getUserId())); + return Response.noContent().build(); + } catch (final Exception e) { + Logger.error(this.getClass(), "Error clearing headless config", e); + return ResponseUtil.mapExceptionResponse(e); + } + } + + @POST + @Path("/export") + @JSONP + @NoCache + @Consumes(MediaType.APPLICATION_JSON) + @Produces({MediaType.APPLICATION_OCTET_STREAM, MediaType.APPLICATION_JSON}) + @Operation(operationId = "exportDotAuthAppSecrets", + summary = "Export all dotAuth AppSecrets", + description = "Exports OAuth/OIDC, SAML, and headless dotAuth AppSecrets into one encrypted Apps export file.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Encrypted export file", + content = @Content(mediaType = "application/octet-stream")), + @ApiResponse(responseCode = "400", description = "Invalid password or no secrets configured"), + @ApiResponse(responseCode = "401", description = "Authentication required"), + @ApiResponse(responseCode = "403", description = "Admin access required"), + @ApiResponse(responseCode = "500", description = "Internal server error") + }) + public final Response exportDotAuthSecrets(@Context final HttpServletRequest request, + @Context final HttpServletResponse response, + final Map form) { + try { + final User user = initUser(request, response); + requireAdmin(user); + final String password = exportPassword(form); + final Map> appKeysBySite = dotAuthAppKeysBySite(user); + final Key key = AppsUtil.generateKey(AppsUtil.loadPass(() -> password)); + final java.nio.file.Path exportFile = appsAPI.exportSecrets(key, false, appKeysBySite, user); + SecurityLogger.logInfo(DotAuthResource.class, + String.format("User %s exported dotAuth AppSecrets", user.getUserId())); + final StreamingOutput stream = (OutputStream out) -> { + try (InputStream in = Files.newInputStream(exportFile)) { + in.transferTo(out); + } finally { + Files.deleteIfExists(exportFile); + } + }; + return Response.ok(stream, MediaType.APPLICATION_OCTET_STREAM) + .header(HttpHeaders.CONTENT_DISPOSITION, + "attachment; filename=dotauth-appSecrets.export") + .build(); + } catch (final Exception e) { + Logger.error(this.getClass(), "Error exporting dotAuth AppSecrets", e); + return ResponseUtil.mapExceptionResponse(e); + } + } + + @POST + @Path("/import") + @JSONP + @NoCache + @Consumes(MediaType.MULTIPART_FORM_DATA) + @Produces({MediaType.APPLICATION_JSON, "application/javascript"}) + @Operation(operationId = "importDotAuthAppSecrets", + summary = "Import dotAuth AppSecrets", + description = "Imports an encrypted Apps export file containing only OAuth/OIDC, SAML, and headless dotAuth AppSecrets.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Import succeeded", + content = @Content(mediaType = "application/json", + schema = @Schema(type = "object", + description = "Import result with count of imported secrets"))), + @ApiResponse(responseCode = "400", description = "Invalid password, empty file, or non-dotAuth secrets in file"), + @ApiResponse(responseCode = "401", description = "Authentication required"), + @ApiResponse(responseCode = "403", description = "Admin access required"), + @ApiResponse(responseCode = "500", description = "Internal server error") + }) + public final Response importDotAuthSecrets(@Context final HttpServletRequest request, + @Context final HttpServletResponse response, + final FormDataMultiPart form) { + try { + final User user = initUser(request, response); + requireAdmin(user); + final MultiPartUtils multiPartUtils = new MultiPartUtils(); + final Map body = multiPartUtils.getBodyMapFromMultipart(form); + final String password = exportPassword(body); + final Key key = AppsUtil.generateKey(AppsUtil.loadPass(() -> password)); + final List files = multiPartUtils.getBinariesFromMultipart(form); + if (files == null || files.isEmpty()) { + throw new BadRequestException("Import file is required"); + } + + int imported = 0; + try { + for (final File file : files) { + if (file.length() == 0) { + throw new BadRequestException("Import file is empty"); + } + imported += importDotAuthFile(file.toPath(), key, user); + } + } finally { + cleanupUploadedFiles(files); + } + SecurityLogger.logInfo(DotAuthResource.class, + String.format("User %s imported %s dotAuth AppSecrets", user.getUserId(), imported)); + return Response.ok(new ResponseEntityView<>(Map.of("imported", imported))).build(); + } catch (final Exception e) { + Logger.error(this.getClass(), "Error importing dotAuth AppSecrets", e); + return ResponseUtil.mapExceptionResponse(e); + } + } + + @POST + @Path("/discover/oidc") + @JSONP + @NoCache + @Consumes(MediaType.APPLICATION_JSON) + @Produces({MediaType.APPLICATION_JSON, "application/javascript"}) + @Operation(operationId = "discoverDotAuthOidc", + summary = "Fetch and parse an OIDC discovery document", + description = "Thin authenticated proxy used by the dotAuth portlet to populate " + + "issuer, endpoint, JWKS, and supported algorithm fields from a " + + ".well-known/openid-configuration URL.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Discovery document parsed successfully", + content = @Content(mediaType = "application/json", + schema = @Schema(type = "object", + description = "Parsed OIDC discovery fields: issuer, endpoints, JWKS URI, and signing algorithms"))), + @ApiResponse(responseCode = "400", description = "Missing or invalid discovery URL"), + @ApiResponse(responseCode = "401", description = "Authentication required"), + @ApiResponse(responseCode = "500", description = "Internal server error") + }) + public final Response discoverOidc(@Context final HttpServletRequest request, + @Context final HttpServletResponse response, + final Map form) { + try { + final User user = initUser(request, response); + final String url = form == null ? null : String.valueOf(form.get("url")); + if (!UtilMethods.isSet(url)) { + throw new BadRequestException("Discovery URL is required"); + } + validateProxyUrl(url); + final String body = CircuitBreakerUrl.builder() + .setUrl(url) + .setTimeout(8_000) + .setHeaders(ImmutableMap.of("Accept", MediaType.APPLICATION_JSON)) + .setMaxResponseBytes(OIDC_DISCOVERY_MAX_RESPONSE_BYTES) + .build() + .doString(); + final JsonNode doc = MAPPER.readTree(body); + final Map entity = Map.of( + "issuer", text(doc, "issuer"), + "authorizationEndpoint", text(doc, "authorization_endpoint"), + "tokenEndpoint", text(doc, "token_endpoint"), + "jwksUri", text(doc, "jwks_uri"), + "userinfoEndpoint", text(doc, "userinfo_endpoint"), + "endSessionEndpoint", text(doc, "end_session_endpoint"), + "signingAlgs", doc.has("id_token_signing_alg_values_supported") + ? MAPPER.convertValue( + doc.path("id_token_signing_alg_values_supported"), List.class) + : List.of()); + SecurityLogger.logInfo(DotAuthResource.class, + String.format("User %s ran dotAuth OIDC discovery", user.getUserId())); + return Response.ok(new ResponseEntityView<>(entity)).build(); + } catch (final Exception e) { + Logger.error(this.getClass(), "Error discovering dotAuth OIDC metadata", e); + return ResponseUtil.mapExceptionResponse(e); + } + } + + @POST + @Path("/fetch/saml-metadata") + @JSONP + @NoCache + @Consumes(MediaType.APPLICATION_JSON) + @Produces({MediaType.APPLICATION_JSON, "application/javascript"}) + @Operation(operationId = "fetchSamlMetadata", + summary = "Fetch SAML IdP metadata XML from a URL", + description = "Authenticated proxy that fetches SAML metadata XML from an IdP's " + + "metadata endpoint URL. Returns the raw XML as a string so the admin " + + "can review it before saving.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Metadata fetched successfully", + content = @Content(mediaType = "application/json", + schema = @Schema(type = "object", + description = "Object with a single 'xml' field containing the raw metadata XML"))), + @ApiResponse(responseCode = "400", description = "Missing or invalid metadata URL"), + @ApiResponse(responseCode = "401", description = "Authentication required"), + @ApiResponse(responseCode = "500", description = "Internal server error") + }) + public final Response fetchSamlMetadata(@Context final HttpServletRequest request, + @Context final HttpServletResponse response, + final Map form) { + try { + final User user = initUser(request, response); + final String url = form == null ? null : String.valueOf(form.get("url")); + if (!UtilMethods.isSet(url)) { + throw new BadRequestException("Metadata URL is required"); + } + validateProxyUrl(url); + final String xml = CircuitBreakerUrl.builder() + .setUrl(url) + .setTimeout(8_000) + .setHeaders(ImmutableMap.of("Accept", + "application/samlmetadata+xml, application/xml, text/xml")) + .setMaxResponseBytes(SAML_METADATA_MAX_RESPONSE_BYTES) + .build() + .doString(); + SecurityLogger.logInfo(DotAuthResource.class, + String.format("User %s fetched SAML metadata from %s", user.getUserId(), url)); + return Response.ok(new ResponseEntityView<>(Map.of("xml", xml))).build(); + } catch (final Exception e) { + Logger.error(this.getClass(), "Error fetching SAML metadata", e); + return ResponseUtil.mapExceptionResponse(e); + } + } + + @GET + @Path("/saml/metadata/{hostId}") + @NoCache + @Produces({MediaType.APPLICATION_XML, "application/xml"}) + @Operation(operationId = "getSamlSpMetadata", + summary = "Download SAML SP metadata XML for a site", + description = "Generates SAML Service Provider metadata XML for the given site. " + + "For inherited configs the certificate comes from the SYSTEM_HOST row, but " + + "the entity ID and ACS URL use the target site's hostname so the IdP can " + + "distinguish per-site requests.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "SP metadata XML", + content = @Content(mediaType = "application/xml")), + @ApiResponse(responseCode = "401", description = "Authentication required"), + @ApiResponse(responseCode = "404", description = "No SAML configuration found for this site"), + @ApiResponse(responseCode = "500", description = "Internal server error") + }) + public final Response getSamlSpMetadata(@Context final HttpServletRequest request, + @Context final HttpServletResponse response, + @PathParam("hostId") final String hostId) { + try { + final User user = initUser(request, response); + final Host host = resolveHost(hostId, user); + + final ProtocolHandler samlHandler = handlers.get(DotAuthProtocol.SAML); + final Optional secrets = appsAPI.getSecrets( + samlHandler.appKey(), true, host, user); + if (secrets.isEmpty()) { + throw new DoesNotExistException( + "No SAML configuration found for site " + hostId); + } + + final Map vals = samlHandler.maskedValues(secrets.get()); + final String hostname = host.isSystemHost() + ? String.valueOf(vals.getOrDefault("sPEndpointHostname", "")) + : host.getHostname(); + + final String storedEntityId = String.valueOf(vals.getOrDefault("sPIssuerURL", "")); + final String entityId = UtilMethods.isSet(storedEntityId) + ? storedEntityId : "https://" + hostname; + final String acsUrl = "https://" + hostname + "/dotsaml/login"; + + final String certPem = String.valueOf(vals.getOrDefault("publicCert", "")); + // Strip PEM armor/whitespace, then constrain to the base64 alphabet so a + // stored value containing XML metacharacters cannot break (or inject markup + // into) the generated SP-metadata document. A real certificate is unaffected. + final String certBase64 = certPem + .replace("-----BEGIN CERTIFICATE-----", "") + .replace("-----END CERTIFICATE-----", "") + .replaceAll("\\s+", "") + .replaceAll("[^A-Za-z0-9+/=]", ""); + + final String sigType = String.valueOf(vals.getOrDefault("signatureValidationType", "")); + final boolean wantAssertionsSigned = + "assertion".equalsIgnoreCase(sigType) + || "responseandassertion".equalsIgnoreCase(sigType); + final boolean wantResponseSigned = + "response".equalsIgnoreCase(sigType) + || "responseandassertion".equalsIgnoreCase(sigType); + + final String xml = buildSpMetadataXml( + entityId, acsUrl, certBase64, wantAssertionsSigned, wantResponseSigned); + + return Response.ok(xml, MediaType.APPLICATION_XML_TYPE) + .header("Content-Disposition", + "attachment; filename=\"saml-sp-metadata-" + hostname + ".xml\"") + .build(); + } catch (final Exception e) { + Logger.error(this.getClass(), + "Error generating SAML SP metadata for " + hostId, e); + return ResponseUtil.mapExceptionResponse(e); + } + } + + private static String buildSpMetadataXml(final String entityId, + final String acsUrl, + final String certBase64, + final boolean wantAssertionsSigned, + final boolean wantResponseSigned) { + final StringBuilder sb = new StringBuilder(); + sb.append("\n"); + sb.append("\n"); + sb.append(" \n"); + if (UtilMethods.isSet(certBase64)) { + sb.append(" \n"); + sb.append(" \n"); + sb.append(" \n"); + sb.append(" ").append(xmlEscape(certBase64)).append("\n"); + sb.append(" \n"); + sb.append(" \n"); + sb.append(" \n"); + } + sb.append(" urn:oasis:names:tc:SAML:2.0:nameid-format:transient\n"); + sb.append(" \n"); + sb.append(" \n"); + sb.append("\n"); + return sb.toString(); + } + + private static String xmlEscape(final String value) { + if (value == null) { + return ""; + } + return value.replace("&", "&").replace("<", "<") + .replace(">", ">").replace("\"", """).replace("'", "'"); + } + + @POST + @Path("/sessionrefs/revoke") + @JSONP + @NoCache + @Produces({MediaType.APPLICATION_JSON, "application/javascript"}) + @Operation(operationId = "revokeDotAuthSessionRefs", + summary = "Revoke all dotAuth sessionRefs", + description = "Flushes the dotAuth sessionRef cache. Existing browser sessions are not affected.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "All session-refs revoked", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ResponseEntityStringView.class))), + @ApiResponse(responseCode = "401", description = "Authentication required"), + @ApiResponse(responseCode = "403", description = "Admin access required"), + @ApiResponse(responseCode = "500", description = "Internal server error") + }) + public final Response revokeAllSessionRefs(@Context final HttpServletRequest request, + @Context final HttpServletResponse response) { + try { + final User user = initUser(request, response); + requireAdmin(user); + sessionCache.invalidateAll(); + SecurityLogger.logInfo(DotAuthResource.class, + String.format("User %s revoked all dotAuth sessionRefs", user.getUserId())); + return Response.ok(new ResponseEntityStringView(OK)).build(); + } catch (final Exception e) { + Logger.error(this.getClass(), "Error revoking dotAuth sessionRefs", e); + return ResponseUtil.mapExceptionResponse(e); + } + } + + private Optional detectProtocol(final Set hostKeys) { + for (final ProtocolHandler handler : handlers.values()) { + if (hostKeys.contains(handler.appKey().toLowerCase())) { + return Optional.of(handler.protocol()); + } + } + return Optional.empty(); + } + + private Map> dotAuthAppKeysBySite(final User user) + throws DotDataException, DotSecurityException { + final Map> selected = new LinkedHashMap<>(); + final Map> appKeysByHost = appsAPI.appKeysByHost(); + final String systemHostIdentifier = APILocator.systemHost().getIdentifier(); + + for (final Map.Entry> entry : appKeysByHost.entrySet()) { + final Set dotAuthKeys = new LinkedHashSet<>(); + for (final String key : entry.getValue()) { + canonicalDotAuthAppKey(key).ifPresent(dotAuthKeys::add); + } + if (!dotAuthKeys.isEmpty()) { + final String siteId = entry.getKey().equalsIgnoreCase(systemHostIdentifier) + ? Host.SYSTEM_HOST + : entry.getKey(); + selected.put(siteId, dotAuthKeys); + } + } + + if (selected.isEmpty()) { + throw new BadRequestException("No dotAuth AppSecrets are configured to export"); + } + return selected; + } + + // Package-private for unit testing (see DotAuthResourceTest). + int importDotAuthFile(final java.nio.file.Path importFile, final Key key, final User user) + throws Exception { + int count = 0; + final Map> importedSecretsBySiteId = + AppsUtil.importSecrets(importFile, key); + for (final Map.Entry> entry : importedSecretsBySiteId.entrySet()) { + final Host host = Host.SYSTEM_HOST.equalsIgnoreCase(entry.getKey()) + ? APILocator.systemHost() + : APILocator.getHostAPI().find(entry.getKey(), user, false); + if (host == null) { + throw new BadRequestException("No site found for imported id `" + entry.getKey() + "`"); + } + for (final AppSecrets appSecrets : entry.getValue()) { + if (appSecrets == null || appSecrets.getSecrets().isEmpty()) { + throw new BadRequestException("Incoming dotAuth AppSecrets entry is empty"); + } + final String canonicalKey = canonicalDotAuthAppKey(appSecrets.getKey()) + .orElseThrow(() -> new BadRequestException( + "Import file contains non-dotAuth AppSecrets key `" + + appSecrets.getKey() + "`")); + if (DotAuthConstants.HEADLESS_APP_KEY.equals(canonicalKey) && !host.isSystemHost()) { + throw new BadRequestException("Headless dotAuth config may only be imported to SYSTEM_HOST"); + } + // No App Descriptor check here: dotAuth OAuth/headless configs store secrets + // directly (no descriptor YAML exists, only SAML has one), mirroring the save + // path which never requires a descriptor. canonicalDotAuthAppKey already + // restricts canonicalKey to the known dotAuth keys. + appsAPI.saveSecrets(AppSecrets.builder() + .withKey(canonicalKey) + .withSecrets(appSecrets.getSecrets()) + .build(), host, user); + count++; + } + } + return count; + } + + private static Optional canonicalDotAuthAppKey(final String appKey) { + if (appKey == null) { + return Optional.empty(); + } + return DOTAUTH_APP_KEYS.stream() + .filter(key -> key.equalsIgnoreCase(appKey)) + .findFirst(); + } + + private static String exportPassword(final Map form) { + final Object raw = form == null ? null : form.get("password"); + final String password = raw == null ? null : String.valueOf(raw); + if (password == null + || password.length() < EXPORT_PASSWORD_MIN_LENGTH + || password.length() > EXPORT_PASSWORD_MAX_LENGTH) { + throw new BadRequestException(String.format( + "Password must be between %s and %s characters", + EXPORT_PASSWORD_MIN_LENGTH, EXPORT_PASSWORD_MAX_LENGTH)); + } + return password; + } + + private static void requireAdmin(final User user) throws DotSecurityException { + if (user == null || !user.isAdmin()) { + throw new DotSecurityException("Only Admins are allowed to import dotAuth AppSecrets"); + } + } + + private static void cleanupUploadedFiles(final List files) { + for (final File file : files) { + if (file == null) { + continue; + } + final File parent = file.getParentFile(); + if (!file.delete()) { + Logger.debug(DotAuthResource.class, + "Unable to remove uploaded dotAuth import file `" + file.getAbsolutePath() + "`"); + } + if (parent != null && parent.isDirectory() && parent.getName().startsWith("tmp_upload")) { + parent.delete(); + } + } + } + + private static String text(final JsonNode doc, final String field) { + final JsonNode value = doc.path(field); + return value.isTextual() ? value.asText() : ""; + } + + private Optional secretsToPreserve(final ProtocolHandler active, + final Host host, + final User user) + throws DotDataException, DotSecurityException { + return appsAPI.getSecrets(active.appKey(), true, host, user); + } + + private Optional findConfiguredProtocol(final Host host, + final User user, + final boolean fallbackOnSystemHost) + throws DotDataException, DotSecurityException { + ProtocolConfig selected = null; + long selectedSavedAt = Long.MIN_VALUE; + for (final ProtocolHandler handler : handlers.values()) { + final Optional secrets = appsAPI.getSecrets( + handler.appKey(), fallbackOnSystemHost, host, user); + if (secrets.isEmpty()) { + continue; + } + final long savedAt = lastSavedAt(secrets.get()); + if (selected == null || savedAt > selectedSavedAt) { + selected = new ProtocolConfig( + handler.protocol(), handler.maskedValues(secrets.get())); + selectedSavedAt = savedAt; + } + } + return Optional.ofNullable(selected); + } + + private static AppSecrets withLastSavedMarker(final AppSecrets appSecrets) { + return AppSecrets.builder() + .withKey(appSecrets.getKey()) + .withSecrets(appSecrets.getSecrets()) + .withSecret(DotAuthConstants.LAST_SAVED_PROTOCOL_AT_KEY, + Secret.builder() + .withValue(String.valueOf(System.currentTimeMillis())) + .withHidden(false) + .withType(Type.STRING) + .build()) + .build(); + } + + private static long lastSavedAt(final AppSecrets appSecrets) { + return Optional.ofNullable(appSecrets.getSecrets() + .get(DotAuthConstants.LAST_SAVED_PROTOCOL_AT_KEY)) + .map(secret -> { + try { + return Long.parseLong(secret.getString()); + } catch (final NumberFormatException e) { + return Long.MIN_VALUE; + } + }) + .orElse(Long.MIN_VALUE); + } + + private static final class ProtocolConfig { + private final DotAuthProtocol protocol; + private final Map values; + + private ProtocolConfig(final DotAuthProtocol protocol, final Map values) { + this.protocol = protocol; + this.values = values; + } + } + + private User initUser(final HttpServletRequest request, final HttpServletResponse response) { + final InitDataObject initData = new WebResource.InitBuilder(webResource) + .requiredBackendUser(true) + .requiredFrontendUser(false) + .requiredPortlet(PortletID.DOT_AUTH.toString()) + .requestAndResponse(request, response) + .rejectWhenNoUser(true) + .init(); + return initData.getUser(); + } + + private static void validateProxyUrl(final String url) { + final String rejection = OAuthSsrfGuard.validateUrl(url); + if (rejection != null) { + throw new BadRequestException(rejection); + } + } + + private Host resolveHost(final String hostId, final User user) + throws DotDataException, DotSecurityException { + if (SYSTEM_HOST_SENTINEL.equalsIgnoreCase(hostId)) { + return APILocator.systemHost(); + } + final Host host = APILocator.getHostAPI().find(hostId, user, false); + if (host == null) { + throw new DoesNotExistException(String.format("No site found for id `%s`", hostId)); + } + return host; + } +} diff --git a/dotCMS/src/main/java/com/dotcms/auth/dotAuth/rest/DotAuthSessionCredentialProcessor.java b/dotCMS/src/main/java/com/dotcms/auth/dotAuth/rest/DotAuthSessionCredentialProcessor.java new file mode 100644 index 000000000000..dddbb5d6c091 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/auth/dotAuth/rest/DotAuthSessionCredentialProcessor.java @@ -0,0 +1,29 @@ +package com.dotcms.auth.dotAuth.rest; + +import com.liferay.portal.model.User; +import java.io.Serializable; +import javax.servlet.http.HttpServletRequest; + +/** + * Credential processor for dotAuth session-refs. Sibling of + * {@code JsonWebTokenAuthCredentialProcessor}: it owns {@code Authorization: + * Bearer} headers whose credential begins with + * {@link com.dotcms.auth.dotAuth.session.DotAuthSessionCache#SESSION_REF_PREFIX} + * and leaves everything else for downstream processors to handle. + */ +public interface DotAuthSessionCredentialProcessor extends Serializable { + + /** + * If the request carries an {@code Authorization: Bearer dsr_…} header, resolve + * the referenced dotAuth session to its {@link User} and return it. Returns + * {@code null} when the header is absent, uses a different scheme, or does not + * carry the dotAuth session-ref prefix — callers should then fall through to the + * next credential processor in the chain. + * + * @throws com.dotcms.rest.exception.SecurityException with 401 when the prefix + * matches but the session-ref is unknown or expired. Prefix-match means + * the caller explicitly intended a dotAuth session, so we reject + * rather than silently falling through to the JWT processor. + */ + User processAuthHeaderFromSessionRef(HttpServletRequest request); +} diff --git a/dotCMS/src/main/java/com/dotcms/auth/dotAuth/rest/DotAuthSessionCredentialProcessorImpl.java b/dotCMS/src/main/java/com/dotcms/auth/dotAuth/rest/DotAuthSessionCredentialProcessorImpl.java new file mode 100644 index 000000000000..e709b7f20cd6 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/auth/dotAuth/rest/DotAuthSessionCredentialProcessorImpl.java @@ -0,0 +1,76 @@ +package com.dotcms.auth.dotAuth.rest; + +import com.dotcms.auth.dotAuth.session.DotAuthSession; +import com.dotcms.auth.dotAuth.session.DotAuthSessionCache; +import com.dotmarketing.business.CacheLocator; +import com.dotcms.rest.exception.SecurityException; +import com.dotmarketing.business.APILocator; +import com.dotmarketing.util.Logger; +import com.google.common.annotations.VisibleForTesting; +import com.liferay.portal.model.User; +import java.util.Optional; +import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.core.Response; +import org.apache.commons.lang.StringUtils; +import org.glassfish.jersey.server.ContainerRequest; + +public final class DotAuthSessionCredentialProcessorImpl implements DotAuthSessionCredentialProcessor { + + private static final long serialVersionUID = 1L; + + private static final String BEARER = "Bearer "; + + private final DotAuthSessionCache sessionCache; + + private static final class SingletonHolder { + private static final DotAuthSessionCredentialProcessorImpl INSTANCE = + new DotAuthSessionCredentialProcessorImpl(CacheLocator.getDotAuthSessionCache()); + } + + public static DotAuthSessionCredentialProcessorImpl getInstance() { + return SingletonHolder.INSTANCE; + } + + @VisibleForTesting + DotAuthSessionCredentialProcessorImpl(final DotAuthSessionCache sessionCache) { + this.sessionCache = sessionCache; + } + + @Override + public User processAuthHeaderFromSessionRef(final HttpServletRequest request) { + final String header = request.getHeader(ContainerRequest.AUTHORIZATION); + if (StringUtils.isEmpty(header) || !header.startsWith(BEARER)) { + return null; + } + final String candidate = header.substring(BEARER.length()).trim(); + if (!candidate.startsWith(DotAuthSessionCache.SESSION_REF_PREFIX)) { + return null; + } + + final Optional sessionOpt = sessionCache.get(candidate); + if (sessionOpt.isEmpty()) { + // Prefix matched, so the caller meant a dotAuth session-ref. Don't fall through + // to the JWT processor with a credential we already know won't parse as a JWT. + throw new SecurityException("Invalid or expired session", Response.Status.UNAUTHORIZED); + } + + final String userId = sessionOpt.get().getUserId(); + try { + final User user = APILocator.getUserAPI().loadUserById(userId); + if (user == null || !user.isActive()) { + sessionCache.invalidate(candidate); + throw new SecurityException("User for session is no longer active", + Response.Status.UNAUTHORIZED); + } + request.setAttribute(com.liferay.portal.util.WebKeys.USER_ID, user.getUserId()); + request.setAttribute(com.liferay.portal.util.WebKeys.USER, user); + return user; + } catch (final SecurityException se) { + throw se; + } catch (final Exception e) { + Logger.warn(DotAuthSessionCredentialProcessorImpl.class, + "Failed resolving user for dotAuth session-ref: " + e.getMessage()); + throw new SecurityException("Invalid session", Response.Status.UNAUTHORIZED); + } + } +} diff --git a/dotCMS/src/main/java/com/dotcms/auth/dotAuth/rest/DotAuthSessionLogoutResource.java b/dotCMS/src/main/java/com/dotcms/auth/dotAuth/rest/DotAuthSessionLogoutResource.java new file mode 100644 index 000000000000..aa340584bed3 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/auth/dotAuth/rest/DotAuthSessionLogoutResource.java @@ -0,0 +1,69 @@ +package com.dotcms.auth.dotAuth.rest; + +import com.dotcms.auth.dotAuth.session.DotAuthSessionCache; +import com.dotmarketing.business.CacheLocator; +import com.dotcms.rest.annotation.NoCache; +import com.dotmarketing.util.SecurityLogger; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.io.Serializable; +import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.DELETE; +import javax.ws.rs.Path; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.Response; +import org.apache.commons.lang.StringUtils; +import org.glassfish.jersey.server.ContainerRequest; + +/** + * Logout companion to {@link DotAuthOAuthExchangeResource}. Invalidates the + * dotAuth session-ref carried in the {@code Authorization: Bearer} header by + * removing its entry from {@link DotAuthSessionCache}. Safe to call + * unconditionally — unknown or malformed refs are silently ignored so a + * "sign out" button does not need to care whether the session had already + * expired server-side. + * + *

Authentication trade-off

+ * This endpoint trusts the bearer header without any additional authentication + * check. An attacker in possession of a stolen session-ref can use it here to + * invalidate the legitimate user's session (forced logout / DoS with no recovery + * other than re-authenticating against the IdP). The impact is strictly weaker + * than the impersonation that same bearer already affords the attacker until it + * expires, which is why we accept it — but the property is worth stating plainly + * so a future hardening pass knows this was an explicit choice rather than an + * oversight. + */ +@Path("/v1/dotauth/oauth") +@Tag(name = "dotAuth", description = "OAuth/OIDC and SAML authentication: per-site configuration (SYSTEM_HOST is the global default) and headless OIDC token exchange") +public class DotAuthSessionLogoutResource implements Serializable { + + private static final long serialVersionUID = 1L; + + private static final String BEARER = "Bearer "; + + private final DotAuthSessionCache sessionCache = CacheLocator.getDotAuthSessionCache(); + + @DELETE + @Path("/session") + @NoCache + @Operation( + summary = "Invalidate the caller's dotAuth session-ref", + description = "Removes the session referenced by the Authorization: Bearer header " + + "from the in-memory session cache. Always returns 204 — unknown or " + + "missing refs are silently ignored so this is safe to call " + + "unconditionally on sign-out.") + @ApiResponses(value = { + @ApiResponse(responseCode = "204", description = "Session invalidated (or was unknown)") + }) + public Response logout(@Context final HttpServletRequest request) { + final String header = request.getHeader(ContainerRequest.AUTHORIZATION); + if (StringUtils.isNotEmpty(header) && header.startsWith(BEARER)) { + sessionCache.invalidate(header.substring(BEARER.length()).trim()); + SecurityLogger.logInfo(DotAuthSessionLogoutResource.class, + "dotAuth session-ref logout requested from " + request.getRemoteAddr()); + } + return Response.noContent().build(); + } +} diff --git a/dotCMS/src/main/java/com/dotcms/auth/dotAuth/rest/DotAuthSiteStatus.java b/dotCMS/src/main/java/com/dotcms/auth/dotAuth/rest/DotAuthSiteStatus.java new file mode 100644 index 000000000000..c4ebb5d7bdfe --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/auth/dotAuth/rest/DotAuthSiteStatus.java @@ -0,0 +1,17 @@ +package com.dotcms.auth.dotAuth.rest; + +/** + * Per-site status for the dotAuth configuration (OAuth or SAML — the same enum + * represents both protocols, since a host can only have one active at a time). + */ +public enum DotAuthSiteStatus { + + /** The site has its own {@code dotAuth} secrets row. */ + SITE_OVERRIDE, + + /** The site has no row of its own but SYSTEM_HOST does. */ + INHERITED, + + /** Neither the site nor SYSTEM_HOST has a row. */ + NOT_CONFIGURED +} diff --git a/dotCMS/src/main/java/com/dotcms/auth/dotAuth/rest/DotAuthSitesView.java b/dotCMS/src/main/java/com/dotcms/auth/dotAuth/rest/DotAuthSitesView.java new file mode 100644 index 000000000000..c17339adbdca --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/auth/dotAuth/rest/DotAuthSitesView.java @@ -0,0 +1,90 @@ +package com.dotcms.auth.dotAuth.rest; + +import java.util.List; + +/** + * Envelope returned by {@code GET /v1/dotauth/sites}. Combines the SYSTEM_HOST + * status (the global default) with the per-site status list used by the + * dotAuth portlet's list view. + */ +public class DotAuthSitesView { + + private final SystemView system; + private final List sites; + + public DotAuthSitesView(final SystemView system, final List sites) { + this.system = system; + this.sites = sites; + } + + public SystemView getSystem() { + return system; + } + + public List getSites() { + return sites; + } + + /** Status of the SYSTEM_HOST (global default) row. */ + public static class SystemView { + + private final boolean configured; + private final DotAuthProtocol protocol; + private final boolean headlessConfigured; + + public SystemView(final boolean configured, + final DotAuthProtocol protocol, + final boolean headlessConfigured) { + this.configured = configured; + this.protocol = protocol; + this.headlessConfigured = headlessConfigured; + } + + public boolean isConfigured() { + return configured; + } + + public DotAuthProtocol getProtocol() { + return protocol; + } + + public boolean isHeadlessConfigured() { + return headlessConfigured; + } + } + + /** One row per non-system site. */ + public static class SiteRowView { + + private final String hostId; + private final String hostName; + private final DotAuthSiteStatus status; + private final DotAuthProtocol protocol; + + public SiteRowView(final String hostId, + final String hostName, + final DotAuthSiteStatus status, + final DotAuthProtocol protocol) { + this.hostId = hostId; + this.hostName = hostName; + this.status = status; + this.protocol = protocol; + } + + public String getHostId() { + return hostId; + } + + public String getHostName() { + return hostName; + } + + public DotAuthSiteStatus getStatus() { + return status; + } + + public DotAuthProtocol getProtocol() { + return protocol; + } + } +} diff --git a/dotCMS/src/main/java/com/dotcms/auth/dotAuth/rest/OAuthExchangeForm.java b/dotCMS/src/main/java/com/dotcms/auth/dotAuth/rest/OAuthExchangeForm.java new file mode 100644 index 000000000000..0e76524fafde --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/auth/dotAuth/rest/OAuthExchangeForm.java @@ -0,0 +1,41 @@ +package com.dotcms.auth.dotAuth.rest; + +import com.dotcms.rest.api.Validated; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import javax.validation.constraints.NotNull; + +/** + * Request body for {@code POST /v1/dotauth/oauth/exchange}. The SPA posts the + * OIDC {@code id_token} it received from the IdP's token endpoint along with the + * {@code nonce} it originated for that authentication request. + * + *

The server validates the id_token (signature via JWKS, {@code iss}, + * {@code aud} == configured client_id, {@code exp}, and {@code nonce}) before + * provisioning or issuing anything — see + * {@link com.dotcms.auth.providers.oauth.provider.OIDCProvider#validateIdTokenAndExtractClaims}. + */ +public class OAuthExchangeForm extends Validated { + + @NotNull + private final String idToken; + + @NotNull + private final String nonce; + + /** Requested JWT lifetime in days; clamped by server to the configured max. */ + private final int expirationDays; + + @JsonCreator + public OAuthExchangeForm(@JsonProperty("idToken") final String idToken, + @JsonProperty("nonce") final String nonce, + @JsonProperty("expirationDays") final Integer expirationDays) { + this.idToken = idToken; + this.nonce = nonce; + this.expirationDays = expirationDays == null ? 0 : expirationDays; + } + + public String getIdToken() { return idToken; } + public String getNonce() { return nonce; } + public int getExpirationDays() { return expirationDays; } +} diff --git a/dotCMS/src/main/java/com/dotcms/auth/dotAuth/rest/OAuthExchangeView.java b/dotCMS/src/main/java/com/dotcms/auth/dotAuth/rest/OAuthExchangeView.java new file mode 100644 index 000000000000..5fe1a2237dcb --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/auth/dotAuth/rest/OAuthExchangeView.java @@ -0,0 +1,64 @@ +package com.dotcms.auth.dotAuth.rest; + +import java.util.List; + +/** + * Response body of {@code POST /v1/dotauth/oauth/exchange}. Carries an opaque + * dotAuth session-ref that the SPA sends as {@code Authorization: Bearer + * } on subsequent content-API calls, the absolute expiry of that + * session, and a minimal identity summary (including applied role keys) for + * the UI to render without a second round-trip. + */ +public class OAuthExchangeView { + + private final String sessionRef; + private final String expiresAt; // ISO-8601 UTC (e.g. "2026-04-30T14:22:11Z") + private final int expirationDays; // mirror of expiresAt for existing SPA code paths + private final UserSummary user; + + public OAuthExchangeView(final String sessionRef, + final String expiresAt, + final int expirationDays, + final UserSummary user) { + this.sessionRef = sessionRef; + this.expiresAt = expiresAt; + this.expirationDays = expirationDays; + this.user = user; + } + + public String getSessionRef() { return sessionRef; } + public String getExpiresAt() { return expiresAt; } + public int getExpirationDays() { return expirationDays; } + public UserSummary getUser() { return user; } + + /** Minimal identity summary returned to the SPA after a successful exchange. */ + public static final class UserSummary { + private final String userId; + private final String email; + private final String firstName; + private final String lastName; + private final String fullName; + private final List roles; + + public UserSummary(final String userId, + final String email, + final String firstName, + final String lastName, + final String fullName, + final List roles) { + this.userId = userId; + this.email = email; + this.firstName = firstName; + this.lastName = lastName; + this.fullName = fullName; + this.roles = roles; + } + + public String getUserId() { return userId; } + public String getEmail() { return email; } + public String getFirstName() { return firstName; } + public String getLastName() { return lastName; } + public String getFullName() { return fullName; } + public List getRoles() { return roles; } + } +} diff --git a/dotCMS/src/main/java/com/dotcms/auth/dotAuth/rest/ResponseEntityDotAuthConfigView.java b/dotCMS/src/main/java/com/dotcms/auth/dotAuth/rest/ResponseEntityDotAuthConfigView.java new file mode 100644 index 000000000000..c264bc6d52b3 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/auth/dotAuth/rest/ResponseEntityDotAuthConfigView.java @@ -0,0 +1,11 @@ +package com.dotcms.auth.dotAuth.rest; + +import com.dotcms.rest.ResponseEntityView; + +/** Response envelope for {@code GET /v1/dotauth/sites/{hostId}}. */ +public class ResponseEntityDotAuthConfigView extends ResponseEntityView { + + public ResponseEntityDotAuthConfigView(final DotAuthConfigView entity) { + super(entity); + } +} diff --git a/dotCMS/src/main/java/com/dotcms/auth/dotAuth/rest/ResponseEntityDotAuthSitesView.java b/dotCMS/src/main/java/com/dotcms/auth/dotAuth/rest/ResponseEntityDotAuthSitesView.java new file mode 100644 index 000000000000..bec1bc4136a8 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/auth/dotAuth/rest/ResponseEntityDotAuthSitesView.java @@ -0,0 +1,11 @@ +package com.dotcms.auth.dotAuth.rest; + +import com.dotcms.rest.ResponseEntityView; + +/** Response envelope for {@code GET /v1/dotauth/sites}. */ +public class ResponseEntityDotAuthSitesView extends ResponseEntityView { + + public ResponseEntityDotAuthSitesView(final DotAuthSitesView entity) { + super(entity); + } +} diff --git a/dotCMS/src/main/java/com/dotcms/auth/dotAuth/rest/ResponseEntityOAuthExchangeView.java b/dotCMS/src/main/java/com/dotcms/auth/dotAuth/rest/ResponseEntityOAuthExchangeView.java new file mode 100644 index 000000000000..247cdd0724c7 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/auth/dotAuth/rest/ResponseEntityOAuthExchangeView.java @@ -0,0 +1,11 @@ +package com.dotcms.auth.dotAuth.rest; + +import com.dotcms.rest.ResponseEntityView; + +/** Response envelope for {@code POST /v1/dotauth/oauth/exchange}. */ +public class ResponseEntityOAuthExchangeView extends ResponseEntityView { + + public ResponseEntityOAuthExchangeView(final OAuthExchangeView entity) { + super(entity); + } +} diff --git a/dotCMS/src/main/java/com/dotcms/auth/dotAuth/rest/handler/HeadlessConfigHelper.java b/dotCMS/src/main/java/com/dotcms/auth/dotAuth/rest/handler/HeadlessConfigHelper.java new file mode 100644 index 000000000000..ac60e3047188 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/auth/dotAuth/rest/handler/HeadlessConfigHelper.java @@ -0,0 +1,103 @@ +package com.dotcms.auth.dotAuth.rest.handler; + +import com.dotcms.auth.dotAuth.DotAuthConstants; +import com.dotcms.security.apps.AppSecrets; +import com.dotcms.security.apps.Secret; +import io.vavr.control.Try; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +/** + * Read/write/mask logic for the {@code dotauth-headless} AppSecrets key. + *

+ * This is NOT a {@link ProtocolHandler} — headless token exchange is not an SSO + * protocol and does not participate in the SAML/OAuth mutual exclusion. + */ +public final class HeadlessConfigHelper { + + public static final String KEY_ENABLED = "enabled"; + public static final String KEY_PROVIDER_TYPE = "providerType"; + public static final String KEY_ISSUER_URL = "issuerUrl"; + public static final String KEY_CLIENT_ID = "clientId"; + public static final String KEY_SCOPES = "scopes"; + public static final String KEY_AUTHORIZATION_URL = "authorizationUrl"; + public static final String KEY_TOKEN_URL = "tokenUrl"; + public static final String KEY_USERINFO_URL = "userinfoUrl"; + public static final String KEY_REVOCATION_URL = "revocationUrl"; + public static final String KEY_LOGOUT_URL = "logoutUrl"; + public static final String KEY_GROUPS_CLAIM = "groupsClaim"; + public static final String KEY_GROUPS_URL = "groupsUrl"; + public static final String KEY_EXTRA_ROLES = "extraRoles"; + public static final String KEY_BUILD_ROLES_STRATEGY = "buildRolesStrategy"; + public static final String KEY_CALLBACK_URL = "callbackUrl"; + public static final String KEY_HASH_USERID = "hashUserId"; + public static final String KEY_SESSION_REF_TTL_MINUTES = "sessionRefTtlMinutes"; + public static final String KEY_CLAMP_TO_IDP_EXP = "clampToIdpExp"; + public static final String KEY_ALLOWED_ORIGINS = "allowedOrigins"; + public static final String KEY_TRUSTED_IDPS = "trustedIdps"; + + static final List SECRET_KEYS = List.of( + KEY_ENABLED, + KEY_PROVIDER_TYPE, + KEY_ISSUER_URL, + KEY_CLIENT_ID, + KEY_SCOPES, + KEY_AUTHORIZATION_URL, + KEY_TOKEN_URL, + KEY_USERINFO_URL, + KEY_REVOCATION_URL, + KEY_LOGOUT_URL, + KEY_GROUPS_CLAIM, + KEY_GROUPS_URL, + KEY_EXTRA_ROLES, + KEY_BUILD_ROLES_STRATEGY, + KEY_CALLBACK_URL, + KEY_HASH_USERID, + KEY_SESSION_REF_TTL_MINUTES, + KEY_CLAMP_TO_IDP_EXP, + KEY_ALLOWED_ORIGINS, + KEY_TRUSTED_IDPS); + + private static final Set BOOLEAN_KEYS = Set.of( + KEY_ENABLED, + KEY_HASH_USERID, + KEY_CLAMP_TO_IDP_EXP); + + public String appKey() { + return DotAuthConstants.HEADLESS_APP_KEY; + } + + public List secretKeys() { + return SECRET_KEYS; + } + + public Map values(final AppSecrets secrets) { + final Map raw = secrets.getSecrets(); + final Map out = new HashMap<>(); + for (final String key : SECRET_KEYS) { + final Secret secret = raw.get(key); + if (secret == null) continue; + if (BOOLEAN_KEYS.contains(key)) { out.put(key, Try.of(secret::getBoolean).getOrElse(false)); continue; } + out.put(key, Try.of(secret::getString).getOrElse("")); + } + return out; + } + + public AppSecrets buildSecrets(final Map incoming) { + final AppSecrets.Builder builder = AppSecrets.builder() + .withKey(DotAuthConstants.HEADLESS_APP_KEY); + for (final String key : SECRET_KEYS) { + final Object raw = incoming.get(key); + if (raw == null) continue; + if (BOOLEAN_KEYS.contains(key)) { + builder.withSecret(key, Boolean.parseBoolean(String.valueOf(raw))); + } else { + builder.withSecret(key, String.valueOf(raw)); + } + } + return builder.build(); + } +} diff --git a/dotCMS/src/main/java/com/dotcms/auth/dotAuth/rest/handler/OAuthProtocolHandler.java b/dotCMS/src/main/java/com/dotcms/auth/dotAuth/rest/handler/OAuthProtocolHandler.java new file mode 100644 index 000000000000..ad9976bad4a2 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/auth/dotAuth/rest/handler/OAuthProtocolHandler.java @@ -0,0 +1,100 @@ +package com.dotcms.auth.dotAuth.rest.handler; + +import com.dotcms.auth.dotAuth.DotAuthConstants; +import com.dotcms.auth.dotAuth.rest.DotAuthProtocol; +import com.dotcms.auth.providers.oauth.OAuthAppConfig; +import com.dotcms.security.apps.AppSecrets; +import com.dotcms.security.apps.Secret; +import io.vavr.control.Try; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +public final class OAuthProtocolHandler implements ProtocolHandler { + + private static final List SECRET_KEYS = List.of( + OAuthAppConfig.KEY_ENABLED, + OAuthAppConfig.KEY_ENABLE_BACKEND, + OAuthAppConfig.KEY_ENABLE_FRONTEND, + OAuthAppConfig.KEY_PROVIDER_TYPE, + OAuthAppConfig.KEY_ISSUER_URL, + OAuthAppConfig.KEY_CLIENT_ID, + OAuthAppConfig.KEY_CLIENT_SECRET, + OAuthAppConfig.KEY_SCOPES, + OAuthAppConfig.KEY_AUTHORIZATION_URL, + OAuthAppConfig.KEY_TOKEN_URL, + OAuthAppConfig.KEY_USERINFO_URL, + OAuthAppConfig.KEY_REVOCATION_URL, + OAuthAppConfig.KEY_LOGOUT_URL, + OAuthAppConfig.KEY_GROUPS_CLAIM, + OAuthAppConfig.KEY_GROUPS_URL, + OAuthAppConfig.KEY_EMAIL_CLAIM, + OAuthAppConfig.KEY_FIRST_NAME_CLAIM, + OAuthAppConfig.KEY_LAST_NAME_CLAIM, + OAuthAppConfig.KEY_GROUP_MAPPINGS, + OAuthAppConfig.KEY_EXTRA_ROLES, + OAuthAppConfig.KEY_BUILD_ROLES_STRATEGY, + OAuthAppConfig.KEY_CALLBACK_URL, + OAuthAppConfig.KEY_HASH_USERID, + OAuthAppConfig.KEY_AUTO_PROVISION); + + private static final Set HIDDEN_KEYS = Set.of( + OAuthAppConfig.KEY_CLIENT_SECRET); + + private static final Set BOOLEAN_KEYS = Set.of( + OAuthAppConfig.KEY_ENABLED, + OAuthAppConfig.KEY_ENABLE_BACKEND, + OAuthAppConfig.KEY_ENABLE_FRONTEND, + OAuthAppConfig.KEY_HASH_USERID, + OAuthAppConfig.KEY_AUTO_PROVISION); + + @Override public DotAuthProtocol protocol() { return DotAuthProtocol.OAUTH; } + @Override public String appKey() { return DotAuthConstants.APP_KEY; } + @Override public List secretKeys() { return SECRET_KEYS; } + @Override public Set hiddenKeys() { return HIDDEN_KEYS; } + @Override public Set booleanKeys() { return BOOLEAN_KEYS; } + + @Override + public Map maskedValues(final AppSecrets secrets) { + final Map raw = secrets.getSecrets(); + final Map out = new HashMap<>(); + for (final String key : SECRET_KEYS) { + final Secret secret = raw.get(key); + if (secret == null) continue; + if (HIDDEN_KEYS.contains(key)) { out.put(key, DotAuthConstants.HIDDEN_SECRET_MASK); continue; } + if (BOOLEAN_KEYS.contains(key)) { out.put(key, Try.of(secret::getBoolean).getOrElse(false)); continue; } + out.put(key, Try.of(secret::getString).getOrElse("")); + } + return out; + } + + @Override + public AppSecrets buildSecrets(final Map incoming, + final Optional existing) { + final AppSecrets.Builder builder = AppSecrets.builder().withKey(DotAuthConstants.APP_KEY); + for (final String key : SECRET_KEYS) { + final Object raw = incoming.get(key); + if (HIDDEN_KEYS.contains(key)) { + final String str = raw == null ? null : String.valueOf(raw); + if (DotAuthConstants.HIDDEN_SECRET_MASK.equals(str)) { + existing.map(AppSecrets::getSecrets) + .map(m -> m.get(key)) + .ifPresent(secret -> builder.withSecret(key, secret)); + continue; + } + if (str == null || str.isEmpty()) continue; + builder.withHiddenSecret(key, str); + continue; + } + if (raw == null) continue; + if (BOOLEAN_KEYS.contains(key)) { + builder.withSecret(key, Boolean.parseBoolean(String.valueOf(raw))); + } else { + builder.withSecret(key, String.valueOf(raw)); + } + } + return builder.build(); + } +} diff --git a/dotCMS/src/main/java/com/dotcms/auth/dotAuth/rest/handler/ProtocolHandler.java b/dotCMS/src/main/java/com/dotcms/auth/dotAuth/rest/handler/ProtocolHandler.java new file mode 100644 index 000000000000..a05c7a1842ef --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/auth/dotAuth/rest/handler/ProtocolHandler.java @@ -0,0 +1,46 @@ +package com.dotcms.auth.dotAuth.rest.handler; + +import com.dotcms.auth.dotAuth.rest.DotAuthProtocol; +import com.dotcms.security.apps.AppSecrets; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +/** + * Per-protocol strategy for reading and writing secrets through {@link + * com.dotcms.security.apps.AppsAPI}. Implementations own their secret-key + * set, their hidden-key set (values masked on GET, preserved on PUT when the + * client posts back the mask), and their boolean-key set (stored as + * primitives rather than strings). + */ +public interface ProtocolHandler { + + /** The protocol this handler serves. */ + DotAuthProtocol protocol(); + + /** AppSecrets key under which this protocol's secrets are stored. */ + String appKey(); + + /** Ordered list of secret keys the portlet is allowed to read/write. */ + List secretKeys(); + + /** Subset of {@link #secretKeys()} whose values are masked on GET. */ + Set hiddenKeys(); + + /** Subset of {@link #secretKeys()} whose values are stored as booleans. */ + Set booleanKeys(); + + /** + * Render an AppSecrets object for the client. Hidden-key values are + * replaced with the mask sentinel; boolean keys are unboxed. + */ + Map maskedValues(AppSecrets secrets); + + /** + * Build an AppSecrets object from a form submission. If {@code existing} + * is present and the incoming value for a hidden key is the mask + * sentinel, the stored value is preserved. + */ + AppSecrets buildSecrets(Map incoming, Optional existing); +} diff --git a/dotCMS/src/main/java/com/dotcms/auth/dotAuth/rest/handler/SamlKeyPairGenerator.java b/dotCMS/src/main/java/com/dotcms/auth/dotAuth/rest/handler/SamlKeyPairGenerator.java new file mode 100644 index 000000000000..ebeb364a8f4e --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/auth/dotAuth/rest/handler/SamlKeyPairGenerator.java @@ -0,0 +1,99 @@ +package com.dotcms.auth.dotAuth.rest.handler; + +import com.dotmarketing.util.Logger; +import com.dotmarketing.util.UtilMethods; +import java.io.StringWriter; +import java.math.BigInteger; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.PrivateKey; +import java.security.SecureRandom; +import java.security.cert.X509Certificate; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Date; +import org.bouncycastle.asn1.pkcs.PrivateKeyInfo; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.cert.X509CertificateHolder; +import org.bouncycastle.cert.X509v3CertificateBuilder; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.openssl.jcajce.JcaPEMWriter; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; + +/** + * Generates a self-signed RSA keypair for SAML SP request signing. + * Called by {@link SamlProtocolHandler} when an admin saves a SAML + * config without providing their own key/cert. + */ +final class SamlKeyPairGenerator { + + private static final int KEY_SIZE = 2048; + private static final long VALIDITY_YEARS = 10; + private static final String SIGNATURE_ALGORITHM = "SHA256WithRSAEncryption"; + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + + private SamlKeyPairGenerator() {} + + static GeneratedKeyPair generate(final String hostname) { + try { + final KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA"); + keyGen.initialize(KEY_SIZE, SECURE_RANDOM); + final KeyPair keyPair = keyGen.generateKeyPair(); + + final String cn = UtilMethods.isSet(hostname) + ? hostname + : "dotCMS SAML SP"; + final X500Name subject = new X500Name("CN=" + cn); + + final Instant now = Instant.now(); + final Date notBefore = Date.from(now); + final Date notAfter = Date.from(now.plus(VALIDITY_YEARS * 365, ChronoUnit.DAYS)); + + final X509v3CertificateBuilder certBuilder = new JcaX509v3CertificateBuilder( + subject, + BigInteger.valueOf(now.toEpochMilli()), + notBefore, + notAfter, + subject, + keyPair.getPublic()); + + final ContentSigner signer = new JcaContentSignerBuilder(SIGNATURE_ALGORITHM) + .build(keyPair.getPrivate()); + final X509CertificateHolder holder = certBuilder.build(signer); + final X509Certificate cert = new JcaX509CertificateConverter().getCertificate(holder); + + return new GeneratedKeyPair(toPem(keyPair.getPrivate()), toPem(cert)); + } catch (Exception e) { + Logger.error(SamlKeyPairGenerator.class, + "Failed to generate SAML SP keypair: " + e.getMessage(), e); + throw new IllegalStateException("SAML SP keypair generation failed", e); + } + } + + private static String toPem(final Object obj) throws Exception { + final StringWriter sw = new StringWriter(); + try (JcaPEMWriter pw = new JcaPEMWriter(sw)) { + if (obj instanceof PrivateKey) { + // Write as PKCS#8 ("BEGIN PRIVATE KEY") — BouncyCastle 1.70 + // defaults to PKCS#1 ("BEGIN RSA PRIVATE KEY") for RSA keys, + // but the SAML bundle's IdpConfigCredentialResolver expects PKCS#8. + pw.writeObject(PrivateKeyInfo.getInstance(((PrivateKey) obj).getEncoded())); + } else { + pw.writeObject(obj); + } + } + return sw.toString().trim(); + } + + static final class GeneratedKeyPair { + final String privateKeyPem; + final String publicCertPem; + + GeneratedKeyPair(final String privateKeyPem, final String publicCertPem) { + this.privateKeyPem = privateKeyPem; + this.publicCertPem = publicCertPem; + } + } +} diff --git a/dotCMS/src/main/java/com/dotcms/auth/dotAuth/rest/handler/SamlProtocolHandler.java b/dotCMS/src/main/java/com/dotcms/auth/dotAuth/rest/handler/SamlProtocolHandler.java new file mode 100644 index 000000000000..2e3965464bed --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/auth/dotAuth/rest/handler/SamlProtocolHandler.java @@ -0,0 +1,153 @@ +package com.dotcms.auth.dotAuth.rest.handler; + +import com.dotcms.auth.dotAuth.DotAuthConstants; +import com.dotcms.auth.dotAuth.rest.DotAuthProtocol; +import com.dotcms.saml.DotSamlProxyFactory; +import com.dotcms.security.apps.AppSecrets; +import com.dotmarketing.util.UtilMethods; +import com.dotcms.security.apps.Secret; +import io.vavr.control.Try; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.regex.Pattern; +import javax.ws.rs.BadRequestException; + +public final class SamlProtocolHandler implements ProtocolHandler { + + /** Ordered to match the schema in dotsaml-config.yml. */ + private static final List SAML_SECRET_KEYS = List.of( + "enable", + "idpName", + "sPIssuerURL", + "sPEndpointHostname", + "signatureValidationType", + "idPMetadataFile", + "publicCert", + "privateKey", + "buttonParam"); + + private static final Set HIDDEN_KEYS = Set.of("privateKey"); + + private static final Set BOOLEAN_KEYS = Set.of("enable"); + + /** + * Extra-key names must be identifier-shaped. Bars whitespace tricks like + * {@code "privateKey "} (trailing space) that would otherwise slip past the + * {@link #SAML_SECRET_KEYS} contains() check and end up stored unmasked. + */ + private static final Pattern EXTRA_KEY_NAME = Pattern.compile("[A-Za-z0-9._-]{1,64}"); + + /** + * Cap per-value length to keep arbitrary extra secrets from turning into a + * cheap storage amplification channel. 8 KiB is well above anything a real + * SAML attribute mapping would need. + */ + private static final int MAX_EXTRA_VALUE_LENGTH = 8 * 1024; + + @Override public DotAuthProtocol protocol() { return DotAuthProtocol.SAML; } + @Override public String appKey() { return DotSamlProxyFactory.SAML_APP_CONFIG_KEY; } + @Override public List secretKeys() { return SAML_SECRET_KEYS; } + @Override public Set hiddenKeys() { return HIDDEN_KEYS; } + @Override public Set booleanKeys() { return BOOLEAN_KEYS; } + + @Override + public Map maskedValues(final AppSecrets secrets) { + final Map raw = secrets.getSecrets(); + final Map out = new HashMap<>(); + // Declared keys first, in YAML order, with hidden/boolean semantics. + for (final String key : SAML_SECRET_KEYS) { + final Secret secret = raw.get(key); + if (secret == null) continue; + if (HIDDEN_KEYS.contains(key)) { out.put(key, DotAuthConstants.HIDDEN_SECRET_MASK); continue; } + if (BOOLEAN_KEYS.contains(key)) { out.put(key, Try.of(secret::getBoolean).getOrElse(false)); continue; } + out.put(key, Try.of(secret::getString).getOrElse("")); + } + // Custom attributes: any stored keys not in the declared schema. Emitted + // as strings with no mask — the Apps descriptor has allowExtraParameters + // so admins can store arbitrary SAML-handler settings (e.g. emailAttribute, + // rolesAttribute, autoCreateUsers) alongside the declared params. + for (final Map.Entry entry : raw.entrySet()) { + final String key = entry.getKey(); + if (SAML_SECRET_KEYS.contains(key) + || DotAuthConstants.LAST_SAVED_PROTOCOL_AT_KEY.equals(key)) continue; + out.put(key, Try.of(entry.getValue()::getString).getOrElse("")); + } + return out; + } + + @Override + public AppSecrets buildSecrets(final Map incoming, + final Optional existing) { + final AppSecrets.Builder builder = AppSecrets.builder().withKey(appKey()); + + boolean hasPrivateKey = false; + boolean hasPublicCert = false; + + for (final String key : SAML_SECRET_KEYS) { + final Object raw = incoming.get(key); + if (HIDDEN_KEYS.contains(key)) { + final String str = raw == null ? null : String.valueOf(raw); + if (DotAuthConstants.HIDDEN_SECRET_MASK.equals(str)) { + existing.map(AppSecrets::getSecrets) + .map(m -> m.get(key)) + .ifPresent(secret -> { + builder.withSecret(key, secret); + }); + if ("privateKey".equals(key)) { + hasPrivateKey = existing.map(AppSecrets::getSecrets) + .map(m -> m.get(key)).isPresent(); + } + continue; + } + if (str == null || str.isEmpty()) continue; + builder.withHiddenSecret(key, str); + if ("privateKey".equals(key)) hasPrivateKey = true; + continue; + } + if (raw == null) continue; + if (BOOLEAN_KEYS.contains(key)) { + builder.withSecret(key, Boolean.parseBoolean(String.valueOf(raw))); + } else { + builder.withSecret(key, String.valueOf(raw)); + if ("publicCert".equals(key) && UtilMethods.isSet(String.valueOf(raw))) { + hasPublicCert = true; + } + } + } + + if (!hasPrivateKey && !hasPublicCert) { + final String hostname = incoming.get("sPEndpointHostname") != null + ? String.valueOf(incoming.get("sPEndpointHostname")) + : null; + final SamlKeyPairGenerator.GeneratedKeyPair kp = + SamlKeyPairGenerator.generate(hostname); + builder.withHiddenSecret("privateKey", kp.privateKeyPem); + builder.withSecret("publicCert", kp.publicCertPem); + } + + // Pass through any extra keys the client submitted as plain strings. + // The client is authoritative — if it doesn't send a previously-stored + // extra, it's dropped (admins remove rows through the UI). + for (final Map.Entry entry : incoming.entrySet()) { + final String key = entry.getKey(); + if (SAML_SECRET_KEYS.contains(key) + || DotAuthConstants.LAST_SAVED_PROTOCOL_AT_KEY.equals(key) + || entry.getValue() == null) continue; + if (!EXTRA_KEY_NAME.matcher(key).matches()) { + throw new BadRequestException("Invalid SAML extra-attribute key '" + key + + "' — must match " + EXTRA_KEY_NAME.pattern()); + } + final String str = String.valueOf(entry.getValue()); + if (str.isEmpty()) continue; + if (str.length() > MAX_EXTRA_VALUE_LENGTH) { + throw new BadRequestException("SAML extra-attribute '" + key + + "' value exceeds " + MAX_EXTRA_VALUE_LENGTH + " chars"); + } + builder.withSecret(key, str); + } + return builder.build(); + } +} diff --git a/dotCMS/src/main/java/com/dotcms/auth/dotAuth/session/DotAuthSession.java b/dotCMS/src/main/java/com/dotcms/auth/dotAuth/session/DotAuthSession.java new file mode 100644 index 000000000000..2e778c26a83a --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/auth/dotAuth/session/DotAuthSession.java @@ -0,0 +1,44 @@ +package com.dotcms.auth.dotAuth.session; + +import java.io.Serializable; + +/** + * In-memory session record for the dotAuth OAuth-exchange flow. Stored in + * {@link DotAuthSessionCache} and keyed by an opaque, high-entropy session-ref + * that the SPA returns on subsequent calls as a {@code Authorization: Bearer} + * credential. + * + *

Intentionally ephemeral: this is the headless analogue of a servlet + * HTTP session for the OIDC exchange endpoint. Loss of the underlying cache + * entry (node restart, eviction, cluster flush, explicit logout) invalidates + * the session and forces the SPA to re-authenticate with the IdP — the same + * re-auth contract the SAML browser flow has today. + * + *

No fields are persisted to the database. This object is Serializable so + * that Hazelcast-backed cache providers can replicate it across nodes in a + * cluster; nothing more. + */ +public final class DotAuthSession implements Serializable { + + private static final long serialVersionUID = 1L; + + private final String userId; + private final long createdAt; + private final long expiresAt; + + public DotAuthSession(final String userId, + final long createdAt, + final long expiresAt) { + this.userId = userId; + this.createdAt = createdAt; + this.expiresAt = expiresAt; + } + + public String getUserId() { return userId; } + public long getCreatedAt() { return createdAt; } + public long getExpiresAt() { return expiresAt; } + + public boolean isExpired() { + return System.currentTimeMillis() >= expiresAt; + } +} diff --git a/dotCMS/src/main/java/com/dotcms/auth/dotAuth/session/DotAuthSessionCache.java b/dotCMS/src/main/java/com/dotcms/auth/dotAuth/session/DotAuthSessionCache.java new file mode 100644 index 000000000000..dad5b1c8c8ec --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/auth/dotAuth/session/DotAuthSessionCache.java @@ -0,0 +1,59 @@ +package com.dotcms.auth.dotAuth.session; + +import java.util.Optional; + +/** + * Minimal store API for dotAuth OAuth-exchange sessions. The wire credential + * (returned to the SPA) is an opaque high-entropy string; the store maps it to + * a {@link DotAuthSession} carrying the resolved dotCMS user id plus lifetime + * metadata. + */ +public interface DotAuthSessionCache { + + /** + * Prefix that identifies a dotAuth session-ref on the wire. Distinguishes + * session-refs from dotCMS JWTs so the auth chain can short-circuit to the + * session lookup without attempting a JWT parse first. + */ + String SESSION_REF_PREFIX = "dsr_"; + + /** + * Mint and store a new session-ref for the given user, valid for + * {@code lifetimeMillis} from now. Returns the session-ref string the + * caller should hand back to the SPA. + */ + String create(String userId, long lifetimeMillis); + + /** + * Look up the session referred to by {@code sessionRef}. Returns empty + * when the ref is unknown, expired, or does not carry the expected prefix. + * Expired entries are evicted as a side effect of this lookup so the cache + * self-heals under load rather than leaning entirely on provider-side TTL. + */ + Optional get(String sessionRef); + + /** Remove a session-ref (logout). No-op when the ref is unknown. */ + void invalidate(String sessionRef); + + /** Remove every active dotAuth session-ref. Used by dotAuth emergency controls. */ + void invalidateAll(); + + /** + * One-time-use guard for exchanged {@code id_token}s. Records that the token + * identified by {@code tokenFingerprint} (a hash of the id_token) has been consumed + * by the exchange flow. + * + *

Returns {@code true} when this is the first time the fingerprint has been seen + * (the caller may proceed) and {@code false} when it has already been consumed and the + * record has not yet expired (the caller must reject the request as a replay). The + * record self-expires at {@code expiresAtMillis} — set to the token's own {@code exp}, + * after which the token is invalid anyway, so retaining the guard entry past that point + * is unnecessary. + * + *

This is a best-effort check-then-set (the underlying cache offers no atomic + * compare-and-set), so two requests racing within the same instant could both observe + * "first use". That window is irrelevant to the threat this defends against — replay of + * a leaked token minutes/hours later — which it closes. + */ + boolean registerExchangeTokenUse(String tokenFingerprint, long expiresAtMillis); +} diff --git a/dotCMS/src/main/java/com/dotcms/auth/dotAuth/session/DotAuthSessionCacheImpl.java b/dotCMS/src/main/java/com/dotcms/auth/dotAuth/session/DotAuthSessionCacheImpl.java new file mode 100644 index 000000000000..73cd4b5bd031 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/auth/dotAuth/session/DotAuthSessionCacheImpl.java @@ -0,0 +1,139 @@ +package com.dotcms.auth.dotAuth.session; + +import com.dotmarketing.business.Cachable; +import com.dotmarketing.business.CacheLocator; +import com.dotmarketing.business.DotCacheAdministrator; +import com.dotmarketing.util.UtilMethods; +import java.security.SecureRandom; +import java.util.Base64; +import java.util.Optional; + +/** + * Default {@link DotAuthSessionCache} implementation, backed by the dotCMS + * cache administrator. + * + *

Cluster scope: the default cache transport broadcasts + * invalidations only — values are never replicated between nodes. A session-ref + * minted on one node is therefore NOT visible on its peers, and the replay + * guard is enforced per node. Clustered deployments must either route headless + * API traffic with session affinity (sticky on the {@code Authorization} + * header / source) or configure a distributed cache provider (e.g. Redis) for + * {@link #CACHE_GROUP} and {@link #REPLAY_CACHE_GROUP}. + * + *

Session-refs are {@value #ENTROPY_BYTES}-byte random strings encoded + * URL-safe-base64 with the {@link DotAuthSessionCache#SESSION_REF_PREFIX} + * prefix. That gives ~256 bits of entropy — well above the bar for a bearer + * credential — while still fitting in a single HTTP header comfortably. + */ +public final class DotAuthSessionCacheImpl implements DotAuthSessionCache, Cachable { + + /** Named cache group for dotAuth sessions. */ + public static final String CACHE_GROUP = "DotAuthSessionCache"; + + /** Named cache group for the exchanged-id_token one-time-use (replay) guard. */ + public static final String REPLAY_CACHE_GROUP = "DotAuthTokenReplayCache"; + + /** 32 bytes = 256 bits of entropy. */ + private static final int ENTROPY_BYTES = 32; + + private static final SecureRandom RANDOM = new SecureRandom(); + private static final Base64.Encoder B64 = Base64.getUrlEncoder().withoutPadding(); + private static final Object REPLAY_LOCK = new Object(); + + private static final class SingletonHolder { + private static final DotAuthSessionCacheImpl INSTANCE = new DotAuthSessionCacheImpl(); + } + + public static DotAuthSessionCacheImpl getInstance() { + return SingletonHolder.INSTANCE; + } + + private DotAuthSessionCacheImpl() { } + + @Override + public String create(final String userId, final long lifetimeMillis) { + final long now = System.currentTimeMillis(); + final long expiresAt = now + Math.max(0L, lifetimeMillis); + final String ref = SESSION_REF_PREFIX + B64.encodeToString(randomBytes()); + cache().put(ref, new DotAuthSession(userId, now, expiresAt), CACHE_GROUP); + return ref; + } + + @Override + public Optional get(final String sessionRef) { + if (!UtilMethods.isSet(sessionRef) || !sessionRef.startsWith(SESSION_REF_PREFIX)) { + return Optional.empty(); + } + final Object raw = cache().getNoThrow(sessionRef, CACHE_GROUP); + if (!(raw instanceof DotAuthSession)) { + return Optional.empty(); + } + final DotAuthSession session = (DotAuthSession) raw; + if (session.isExpired()) { + // Lazy eviction — don't trust provider-side TTL alone; enforce our own absolute cap. + cache().remove(sessionRef, CACHE_GROUP); + return Optional.empty(); + } + return Optional.of(session); + } + + @Override + public void invalidate(final String sessionRef) { + if (!UtilMethods.isSet(sessionRef) || !sessionRef.startsWith(SESSION_REF_PREFIX)) { + return; + } + cache().remove(sessionRef, CACHE_GROUP); + } + + @Override + public void invalidateAll() { + cache().flushGroup(CACHE_GROUP); + } + + @Override + public boolean registerExchangeTokenUse(final String tokenFingerprint, final long expiresAtMillis) { + if (!UtilMethods.isSet(tokenFingerprint)) { + // No fingerprint to track — let the caller proceed. Callers always derive one, + // so this only guards against a programming error rather than gating real traffic. + return true; + } + // The cache administrator offers no atomic putIfAbsent, so the check-then-put must be + // a critical section or two concurrent exchanges of the same id_token both pass the + // one-time-use guard. Single lock is fine: this runs once per token exchange. + synchronized (REPLAY_LOCK) { + final Object raw = cache().getNoThrow(tokenFingerprint, REPLAY_CACHE_GROUP); + if (raw instanceof Long && System.currentTimeMillis() < (Long) raw) { + // Already consumed and the token has not yet expired -> replay. + return false; + } + cache().put(tokenFingerprint, Long.valueOf(expiresAtMillis), REPLAY_CACHE_GROUP); + return true; + } + } + + @Override + public String getPrimaryGroup() { + return CACHE_GROUP; + } + + @Override + public String[] getGroups() { + return new String[]{CACHE_GROUP, REPLAY_CACHE_GROUP}; + } + + @Override + public void clearCache() { + cache().flushGroup(CACHE_GROUP); + cache().flushGroup(REPLAY_CACHE_GROUP); + } + + private static DotCacheAdministrator cache() { + return CacheLocator.getCacheAdministrator(); + } + + private static byte[] randomBytes() { + final byte[] buf = new byte[ENTROPY_BYTES]; + RANDOM.nextBytes(buf); + return buf; + } +} diff --git a/dotCMS/src/main/java/com/dotcms/auth/providers/oauth/OAuthAppConfig.java b/dotCMS/src/main/java/com/dotcms/auth/providers/oauth/OAuthAppConfig.java new file mode 100644 index 000000000000..0cdb7e084259 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/auth/providers/oauth/OAuthAppConfig.java @@ -0,0 +1,363 @@ +package com.dotcms.auth.providers.oauth; + +import com.dotcms.security.apps.AppSecrets; +import com.dotcms.security.apps.Secret; +import com.dotmarketing.beans.Host; +import com.dotmarketing.business.APILocator; +import com.dotmarketing.business.web.WebAPILocator; +import com.dotmarketing.util.Config; +import com.dotmarketing.util.Logger; +import com.dotmarketing.util.SecurityLogger; +import com.dotmarketing.util.UtilMethods; +import io.vavr.control.Try; +import java.io.Serializable; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Arrays; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import javax.servlet.http.HttpServletRequest; + +/** + * Strongly-typed configuration read from the {@code dotAuth} App secrets. + *

+ * Falls back to the {@code SYSTEM_HOST} secrets if the current site has none — + * standard behavior for App-based integrations in dotCMS. + */ +public final class OAuthAppConfig implements Serializable { + + private static final long serialVersionUID = 1L; + + // Secret keys — previously mirrored dotOAuth.yml params; since phase 2 they are + // edited by the dotAuth portlet, not a YAML descriptor. + public static final String KEY_ENABLED = "enabled"; + public static final String KEY_PROVIDER_TYPE = "providerType"; + public static final String KEY_ISSUER_URL = "issuerUrl"; + public static final String KEY_CLIENT_ID = "clientId"; + public static final String KEY_CLIENT_SECRET = "clientSecret"; + public static final String KEY_SCOPES = "scopes"; + public static final String KEY_AUTHORIZATION_URL = "authorizationUrl"; + public static final String KEY_TOKEN_URL = "tokenUrl"; + public static final String KEY_USERINFO_URL = "userinfoUrl"; + public static final String KEY_REVOCATION_URL = "revocationUrl"; + public static final String KEY_LOGOUT_URL = "logoutUrl"; + public static final String KEY_GROUPS_CLAIM = "groupsClaim"; + public static final String KEY_GROUPS_URL = "groupsUrl"; + public static final String KEY_EMAIL_CLAIM = "emailClaim"; + public static final String KEY_FIRST_NAME_CLAIM = "firstNameClaim"; + public static final String KEY_LAST_NAME_CLAIM = "lastNameClaim"; + public static final String KEY_GROUP_MAPPINGS = "groupMappings"; + public static final String KEY_ENABLE_BACKEND = "enableBackend"; + public static final String KEY_ENABLE_FRONTEND = "enableFrontend"; + public static final String KEY_EXTRA_ROLES = "extraRoles"; + public static final String KEY_BUILD_ROLES_STRATEGY = "buildRolesStrategy"; + public static final String KEY_CALLBACK_URL = "callbackUrl"; + public static final String KEY_HASH_USERID = "hashUserId"; + public static final String KEY_AUTO_PROVISION = "autoProvision"; + + + public final boolean enabled; + public final boolean enableBackend; + public final boolean enableFrontend; + public final boolean hashUserId; + public final boolean autoProvision; + public final String providerType; + public final String issuerUrl; + public final String clientId; + public final char[] clientSecret; + public final String scopes; + public final String authorizationUrl; + public final String tokenUrl; + public final String userinfoUrl; + public final String revocationUrl; + public final String logoutUrl; + public final String groupsClaim; + public final String groupsUrl; + public final String emailClaim; + public final String firstNameClaim; + public final String lastNameClaim; + public final String groupMappingsJson; + public final String[] extraRoles; + public final String buildRolesStrategy; + public final String callbackUrl; + + // Headless session-ref config (only populated on exchange path) + public final int sessionRefTtlMinutes; + public final boolean clampToIdpExp; + public final String allowedOriginsJson; + public final String trustedIdpsJson; + + private OAuthAppConfig(final Map secrets) { + this.enabled = bool(secrets, KEY_ENABLED, false); + this.enableBackend = bool(secrets, KEY_ENABLE_BACKEND, true); + this.enableFrontend = bool(secrets, KEY_ENABLE_FRONTEND, false); + // Default ON. Hashing the namespaced subject keeps user IDs free of ':' (which is + // overloaded as a separator across the cache/permission layers) and bounds them to + // dotcms.user.id.maxlength. Mirrors SAMLHelper's hash.userid default behavior. + this.hashUserId = bool(secrets, KEY_HASH_USERID, true); + this.autoProvision = bool(secrets, KEY_AUTO_PROVISION, true); + this.providerType = str (secrets, KEY_PROVIDER_TYPE, OAuthConstants.PROVIDER_TYPE_OIDC); + this.issuerUrl = validateUrl(str(secrets, KEY_ISSUER_URL, null), KEY_ISSUER_URL); + this.clientId = str (secrets, KEY_CLIENT_ID, null); + this.clientSecret = chars(secrets, KEY_CLIENT_SECRET); + this.scopes = str (secrets, KEY_SCOPES, "openid email profile"); + this.authorizationUrl = validateUrl(str(secrets, KEY_AUTHORIZATION_URL, null), KEY_AUTHORIZATION_URL); + this.tokenUrl = validateUrl(str(secrets, KEY_TOKEN_URL, null), KEY_TOKEN_URL); + this.userinfoUrl = validateUrl(str(secrets, KEY_USERINFO_URL, null), KEY_USERINFO_URL); + this.revocationUrl = validateUrl(str(secrets, KEY_REVOCATION_URL, null), KEY_REVOCATION_URL); + this.logoutUrl = validateUrl(str(secrets, KEY_LOGOUT_URL, null), KEY_LOGOUT_URL); + this.groupsClaim = str (secrets, KEY_GROUPS_CLAIM, null); + this.groupsUrl = validateUrl(str(secrets, KEY_GROUPS_URL, null), KEY_GROUPS_URL); + this.emailClaim = str (secrets, KEY_EMAIL_CLAIM, null); + this.firstNameClaim = str (secrets, KEY_FIRST_NAME_CLAIM, null); + this.lastNameClaim = str (secrets, KEY_LAST_NAME_CLAIM, null); + this.groupMappingsJson = str(secrets, KEY_GROUP_MAPPINGS, null); + this.extraRoles = split(str(secrets, KEY_EXTRA_ROLES, null)); + this.buildRolesStrategy = str(secrets, KEY_BUILD_ROLES_STRATEGY, + Config.getStringProperty("OAUTH_BUILD_ROLES_STRATEGY", "ALL")); + this.callbackUrl = validateUrl(str(secrets, KEY_CALLBACK_URL, null), KEY_CALLBACK_URL); + this.sessionRefTtlMinutes = 0; + this.clampToIdpExp = false; + this.allowedOriginsJson = "[]"; + this.trustedIdpsJson = "[]"; + } + + /** + * Headless exchange constructor — reads exclusively from the {@code dotauth-headless} + * app key with clean key names (no {@code exchange} prefix, no fallback to SSO keys). + */ + private OAuthAppConfig(final Map headlessSecrets, final boolean exchange) { + this.enabled = bool(headlessSecrets, "enabled", false); + this.enableBackend = false; + this.enableFrontend = true; + this.hashUserId = bool(headlessSecrets, "hashUserId", true); + this.autoProvision = bool(headlessSecrets, "autoProvision", true); + this.providerType = str (headlessSecrets, "providerType", OAuthConstants.PROVIDER_TYPE_OIDC); + this.issuerUrl = validateUrl(str(headlessSecrets, "issuerUrl", null), "issuerUrl"); + this.clientId = str (headlessSecrets, "clientId", null); + // Headless exchange is a public-client OIDC flow — no client secret. + this.clientSecret = new char[0]; + this.scopes = str (headlessSecrets, "scopes", "openid email profile"); + this.authorizationUrl = validateUrl(str(headlessSecrets, "authorizationUrl", null), "authorizationUrl"); + this.tokenUrl = validateUrl(str(headlessSecrets, "tokenUrl", null), "tokenUrl"); + this.userinfoUrl = validateUrl(str(headlessSecrets, "userinfoUrl", null), "userinfoUrl"); + this.revocationUrl = validateUrl(str(headlessSecrets, "revocationUrl", null), "revocationUrl"); + this.logoutUrl = validateUrl(str(headlessSecrets, "logoutUrl", null), "logoutUrl"); + this.groupsClaim = str (headlessSecrets, "groupsClaim", null); + this.groupsUrl = validateUrl(str(headlessSecrets, "groupsUrl", null), "groupsUrl"); + this.emailClaim = str (headlessSecrets, "emailClaim", null); + this.firstNameClaim = str (headlessSecrets, "firstNameClaim", null); + this.lastNameClaim = str (headlessSecrets, "lastNameClaim", null); + this.groupMappingsJson = str(headlessSecrets, "groupMappings", null); + this.extraRoles = split(str(headlessSecrets, "extraRoles", null)); + this.buildRolesStrategy = str(headlessSecrets, "buildRolesStrategy", + Config.getStringProperty("OAUTH_BUILD_ROLES_STRATEGY", "ALL")); + this.callbackUrl = validateUrl(str(headlessSecrets, "callbackUrl", null), "callbackUrl"); + + this.sessionRefTtlMinutes = Math.max(0, intVal(str(headlessSecrets, "sessionRefTtlMinutes", "60"))); + this.clampToIdpExp = bool(headlessSecrets, "clampToIdpExp", true); + this.allowedOriginsJson = str(headlessSecrets, "allowedOrigins", "[]"); + this.trustedIdpsJson = str(headlessSecrets, "trustedIdps", "[]"); + } + + /** + * Look up the OAuth config for the request's host, falling back to SYSTEM_HOST. + * Returns empty when no App secrets are set or when the app is not enabled. + */ + public static Optional config(final HttpServletRequest request) { + final Host host = WebAPILocator.getHostWebAPI().getCurrentHostNoThrow(request); + return loadSecrets(host).map(OAuthAppConfig::new).filter(c -> c.enabled); + } + + /** + * System-level lookup for the headless OIDC exchange flow. Reads exclusively from the + * {@code dotauth-headless} app key on SYSTEM_HOST — no per-site config, no fallback to SSO. + */ + public static Optional exchangeConfig(final HttpServletRequest request) { + return loadHeadlessSecrets() + .map(secrets -> new OAuthAppConfig(secrets, true)) + .filter(c -> c.enabled); + } + + /** Site-scoped lookup (used by the ViewTool). */ + public static Optional config(final Host host) { + return loadSecrets(host).map(OAuthAppConfig::new).filter(c -> c.enabled); + } + + private static Optional> loadSecrets(final Host host) { + if (host == null) { + return Optional.empty(); + } + final Optional appSecrets = Try.of(() -> + APILocator.getAppsAPI().getSecrets(OAuthConstants.APP_KEY, true, host, APILocator.systemUser())) + .getOrElse(Optional.empty()); + return appSecrets.map(AppSecrets::getSecrets); + } + + private static Optional> loadHeadlessSecrets() { + final Optional appSecrets = Try.of(() -> + APILocator.getAppsAPI().getSecrets( + com.dotcms.auth.dotAuth.DotAuthConstants.HEADLESS_APP_KEY, + false, APILocator.systemHost(), APILocator.systemUser())) + .getOrElse(Optional.empty()); + return appSecrets.map(AppSecrets::getSecrets); + } + + private static String str(final Map s, final String key, final String def) { + final Secret v = s.get(key); + if (v == null) { + return def; + } + final String val = Try.of(v::getString).getOrNull(); + return UtilMethods.isSet(val) ? val.trim() : def; + } + + private static boolean bool(final Map s, final String key, final boolean def) { + final Secret v = s.get(key); + if (v == null) { + return def; + } + return Try.of(v::getBoolean).getOrElse(def); + } + + private static char[] chars(final Map s, final String key) { + final Secret v = s.get(key); + if (v == null) { + return new char[0]; + } + return Try.of(v::getValue).getOrElse(new char[0]); + } + + private static String[] split(final String csv) { + return UtilMethods.isSet(csv) + ? Arrays.stream(csv.split(",")).map(String::trim).filter(UtilMethods::isSet).toArray(String[]::new) + : new String[0]; + } + + private static int intVal(final String s) { + if (s == null) { + return 0; + } + try { + return Integer.parseInt(s.trim()); + } catch (final NumberFormatException e) { + return 0; + } + } + + public boolean isOidc() { + return OAuthConstants.PROVIDER_TYPE_OIDC.equalsIgnoreCase(providerType); + } + + /** + * Create a copy of this config with per-IdP overrides for claim mappings, + * role behavior, and provisioning settings. Used by the headless exchange + * flow to honor trusted IdP configuration. Callers should normalize + * non-String values (e.g. groupMappings as a parsed List) to Strings + * before calling this method. + */ + public OAuthAppConfig withTrustedIdpOverrides(final Map idp) { + return new OAuthAppConfig(this, idp); + } + + private OAuthAppConfig(final OAuthAppConfig base, final Map idpOverrides) { + this.enabled = base.enabled; + this.enableBackend = base.enableBackend; + this.enableFrontend = base.enableFrontend; + this.hashUserId = base.hashUserId; + this.providerType = base.providerType; + this.issuerUrl = base.issuerUrl; + this.clientId = base.clientId; + this.clientSecret = base.clientSecret; + this.scopes = base.scopes; + this.authorizationUrl = base.authorizationUrl; + this.tokenUrl = base.tokenUrl; + this.userinfoUrl = base.userinfoUrl; + this.revocationUrl = base.revocationUrl; + this.logoutUrl = base.logoutUrl; + this.groupsClaim = base.groupsClaim; + this.groupsUrl = base.groupsUrl; + this.callbackUrl = base.callbackUrl; + this.sessionRefTtlMinutes = base.sessionRefTtlMinutes; + this.clampToIdpExp = base.clampToIdpExp; + this.allowedOriginsJson = base.allowedOriginsJson; + this.trustedIdpsJson = base.trustedIdpsJson; + + this.emailClaim = idpStr(idpOverrides, "claimEmail", base.emailClaim); + this.firstNameClaim = idpStr(idpOverrides, "claimFirstName", base.firstNameClaim); + this.lastNameClaim = idpStr(idpOverrides, "claimLastName", base.lastNameClaim); + this.groupMappingsJson = idpStr(idpOverrides, "groupMappings", base.groupMappingsJson); + this.buildRolesStrategy = idpStr(idpOverrides, "roleBehavior", base.buildRolesStrategy); + this.autoProvision = idpBool(idpOverrides, "autoProvision", base.autoProvision); + final String defaultRolesStr = idpStr(idpOverrides, "defaultRoles", null); + this.extraRoles = defaultRolesStr != null ? split(defaultRolesStr) : base.extraRoles; + } + + private static String idpStr(final Map idp, final String key, final String base) { + final Object v = idp.get(key); + if (v == null) return base; + final String s = String.valueOf(v); + return UtilMethods.isSet(s) ? s : base; + } + + private static boolean idpBool(final Map idp, final String key, final boolean base) { + final Object v = idp.get(key); + if (v == null) return base; + if (v instanceof Boolean) return (Boolean) v; + return Boolean.parseBoolean(String.valueOf(v)); + } + + // ---------- URL validation (SSRF / TLS guards) ---------- + + private static final Set ALLOWED_SCHEMES = Set.of("https", "http"); + + /** + * Validate a configured IdP URL. Rejects non-HTTPS (unless the dev override + * {@code OAUTH_ALLOW_INSECURE_URLS} is set), non-HTTP(S) schemes, and any host + * that resolves to a loopback, link-local, site-local, or any-local address — + * the standard SSRF defense against IMDS (169.254.169.254), localhost, and + * RFC1918 ranges. Returns null on rejection; callers treat a null URL as + * "not configured" and fall through cleanly. + */ + private static String validateUrl(final String url, final String fieldName) { + if (!UtilMethods.isSet(url)) { + return null; + } + final URI uri; + try { + uri = new URI(url); + } catch (final URISyntaxException e) { + rejectUrl(fieldName, "not a valid URI (" + e.getMessage() + ")"); + return null; + } + final String scheme = uri.getScheme() == null ? "" : uri.getScheme().toLowerCase(); + if (!ALLOWED_SCHEMES.contains(scheme)) { + rejectUrl(fieldName, "scheme '" + scheme + "' not allowed"); + return null; + } + final boolean allowInsecure = Config.getBooleanProperty("OAUTH_ALLOW_INSECURE_URLS", false); + if ("http".equals(scheme) && !allowInsecure) { + rejectUrl(fieldName, "http:// is not allowed unless OAUTH_ALLOW_INSECURE_URLS=true"); + return null; + } + final String host = uri.getHost(); + if (!UtilMethods.isSet(host)) { + rejectUrl(fieldName, "URI is missing a host"); + return null; + } + if (!allowInsecure && OAuthSsrfGuard.isInternalHost(host)) { + rejectUrl(fieldName, + "host '" + host + "' resolves to an internal/private address (SSRF guard)"); + return null; + } + return url; + } + + private static void rejectUrl(final String fieldName, final String reason) { + final String msg = "OAuth " + fieldName + " rejected: " + reason; + Logger.warn(OAuthAppConfig.class, msg); + SecurityLogger.logInfo(OAuthAppConfig.class, msg); + } + +} diff --git a/dotCMS/src/main/java/com/dotcms/auth/providers/oauth/OAuthConstants.java b/dotCMS/src/main/java/com/dotcms/auth/providers/oauth/OAuthConstants.java new file mode 100644 index 000000000000..9e6b95c21de2 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/auth/providers/oauth/OAuthConstants.java @@ -0,0 +1,61 @@ +package com.dotcms.auth.providers.oauth; + +import com.dotcms.auth.dotAuth.DotAuthConstants; + +/** + * Shared keys and URL patterns for the core OAuth 2.0 / OpenID Connect integration. + *

+ * Endpoints and session/app keys are deliberately different from the legacy + * {@code plugin-dotcms-oauth} OSGI plugin so both can coexist while customers + * migrate their configuration. + */ +public final class OAuthConstants { + + private OAuthConstants() {} + + /** Apps API key. Kept here for backwards-compatibility with existing call sites; the canonical value lives in {@link DotAuthConstants#APP_KEY}. */ + public static final String APP_KEY = DotAuthConstants.APP_KEY; + + // REST paths + public static final String OAUTH_BASE_PATH = "/api/v1/oauth"; + public static final String CALLBACK_PATH = "/api/v1/oauth/callback"; + + // Logout paths that trigger OAuth-aware logout flow + public static final String[] LOGOUT_PATHS = {"/api/v1/logout", "/dotCMS/logout", "/dotAdmin/logout"}; + + // URL prefixes that require backend authentication + public static final String[] BACK_END_URLS = {"/dotAdmin/", "/c/", "/html/portal/login"}; + public static final String[] FRONT_END_URLS = {"/dotCMS/login", "/application/login/login", "/login"}; + + // Session attribute keys + public static final String SESSION_REDIRECT_URI = "OAUTH_REDIRECT"; + public static final String SESSION_STATE = "OAUTH_STATE"; + public static final String SESSION_NONCE = "OAUTH_NONCE"; + public static final String SESSION_CODE_VERIFIER = "OAUTH_CODE_VERIFIER"; + public static final String SESSION_ACCESS_TOKEN = "OAUTH_ACCESS_TOKEN"; + public static final String SESSION_ID_TOKEN = "OAUTH_ID_TOKEN"; + public static final String SESSION_PROVIDER_TYPE = "OAUTH_PROVIDER_TYPE"; + public static final String SESSION_FRONT_END_LOGIN = "OAUTH_FRONT_END_LOGIN"; + public static final String SESSION_NATIVE_LOGIN = "OAUTH_NATIVE_LOGIN_BYPASS"; + public static final String SESSION_ORIGINAL_REQUEST = "OAUTH_ORIGINAL_REQUEST"; + + // Request params / cookies + public static final String PARAM_NATIVE = "native"; + public static final String PARAM_REFERRER = "referrer"; + public static final String PARAM_CODE = "code"; + public static final String PARAM_STATE = "state"; + public static final String COOKIE_ACCESS_TOKEN = "access_token"; + + // OIDC discovery + public static final String DISCOVERY_PATH = "/.well-known/openid-configuration"; + + // Provider types + public static final String PROVIDER_TYPE_OIDC = "OIDC"; + public static final String PROVIDER_TYPE_OAUTH2 = "OAuth2"; + + // URL patterns to skip OAuth interception (assets, API calls, etc.) + public static final String[] DEFAULT_ALLOWED_URL_FRAGMENTS = { + ".bundle.", ".chunk.", ".js", ".css", ".woff", ".ttf", ".ico", ".png", ".jpg", ".svg", + "/api/", "/authentication", "/loginform", "/logout", "/appconfiguration" + }; +} diff --git a/dotCMS/src/main/java/com/dotcms/auth/providers/oauth/OAuthHelper.java b/dotCMS/src/main/java/com/dotcms/auth/providers/oauth/OAuthHelper.java new file mode 100644 index 000000000000..e098536a048b --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/auth/providers/oauth/OAuthHelper.java @@ -0,0 +1,633 @@ +package com.dotcms.auth.providers.oauth; + +import com.dotcms.auth.providers.oauth.provider.OAuthProvider; +import com.dotcms.enterprise.PasswordFactoryProxy; +import com.dotcms.enterprise.de.qaware.heimdall.PasswordException; +import com.dotmarketing.business.APILocator; +import com.dotmarketing.business.Role; +import com.dotmarketing.cms.factories.PublicEncryptionFactory; +import com.dotmarketing.exception.DotDataException; +import com.dotmarketing.exception.DotRuntimeException; +import com.dotmarketing.exception.DotSecurityException; +import com.google.common.cache.Cache; +import com.google.common.cache.CacheBuilder; +import com.dotmarketing.util.Config; +import com.dotmarketing.util.Logger; +import com.dotmarketing.util.SecurityLogger; +import com.dotmarketing.util.UUIDGenerator; +import com.dotmarketing.util.UtilMethods; +import com.liferay.portal.model.User; +import com.liferay.util.Encryptor; +import io.vavr.control.Try; +import java.nio.charset.StandardCharsets; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Date; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutionException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpSession; + +/** + * Resolves and authenticates a dotCMS {@link User} from an OAuth/OIDC userinfo payload. + * Follows the same contract as {@code SAMLHelper}: lookup → create if missing → role mapping → + * {@code LoginServiceAPI.doCookieLogin()}. + */ +public class OAuthHelper { + + private static final String[] EMAIL_CLAIMS = {"email", "email_address", "emailaddress", "userPrincipalName"}; + private static final String[] FIRST_NAME_CLAIMS = {"first_name", "firstname", "given_name", "givenname"}; + private static final String[] LAST_NAME_CLAIMS = {"last_name", "lastname", "family_name", "familyname", "surname"}; + + /** + * Per-user intrinsic locks that guard the role-wipe + re-apply block in + * {@link #applyBuildRolesStrategy}. Without a lock, two concurrent logins for + * the same user (SPA opening two tabs on session expiry, simultaneous + * requests through {@code /oauth/exchange}) can both step into the + * "removed all, not yet reapplied" window and see the user with zero roles. + * + *

Per-JVM only — this does not coordinate across cluster nodes. Two nodes + * racing on the same user each apply the full role set independently; worst + * case is a redundant reapply of the same roles, which is idempotent. That + * is an accepted cost versus the complexity of a distributed lock. + */ + private static final Cache ROLE_SYNC_LOCKS = + CacheBuilder.newBuilder().maximumSize(10_000).build(); + + /** + * Per-login role-sync strategy, mirroring {@code SAMLHelper}'s {@code build.roles} + * configuration. Controlled per dotAuth OAuth config by {@code buildRolesStrategy}, + * with {@code OAUTH_BUILD_ROLES_STRATEGY} kept as a fallback for configs saved + * before the option existed. Defaults to {@link BuildRolesStrategy#ALL} so an OAuth + * user's dotCMS roles strictly reflect their current IdP group memberships on every login. + * + *

Same semantics as SAMLHelper — any strategy other than {@code STATICADD} or + * {@code NONE} wipes all existing roles from the user before reapplying. This is + * what lets IdP-side group removal actually take effect in dotCMS. + * + *

Behavior change vs. pre-dotAuth OAuth: the earlier OAuth interceptor + * was purely additive — {@code applySystemRole} + {@code applyExtraRoles} + + * {@code applyProviderGroups} with no wipe — so any roles an admin assigned + * through the back-end UI stuck across logins. Under the new default, those + * admin-assigned roles are cleared on the user's next OAuth login unless they + * happen to also be emitted by the IdP as a group. Deployments that relied on + * admin-side role curation should set {@code OAUTH_BUILD_ROLES_STRATEGY=STATICADD} + * (additive, legacy behavior) or {@code NONE} (leaves roles untouched). The + * new {@code ALL} default is the right contract for IdP-driven access control, + * but the switch needs to be called out explicitly in release notes so operators + * know which knob to turn if their flow breaks. + */ + public enum BuildRolesStrategy { + /** Remove all roles, then apply system role + extraRoles + provider groups. */ + ALL, + /** Remove all roles, then apply provider groups only. No baseline user role. */ + IDP, + /** Remove all roles, then apply system role + extraRoles only (ignore groups). */ + STATICONLY, + /** Additive — do NOT remove existing roles, then apply the full role set. */ + STATICADD, + /** Do nothing with roles. Roles must be managed outside the OAuth flow. */ + NONE; + + static BuildRolesStrategy resolve() { + final String configured = Config.getStringProperty("OAUTH_BUILD_ROLES_STRATEGY", ALL.name()); + return resolve(configured); + } + + static BuildRolesStrategy resolve(final String configured) { + return Try.of(() -> BuildRolesStrategy.valueOf(configured.trim().toUpperCase())) + .getOrElse(ALL); + } + } + + /** + * Resolve (or create) a dotCMS user from the userinfo payload and log them in via cookie. + * Returns the authenticated user. + */ + public User authenticate(final HttpServletRequest request, + final HttpServletResponse response, + final OAuthProvider provider, + final String accessToken, + final Map userInfo, + final OAuthAppConfig config, + final boolean frontEndLogin) throws DotDataException { + + final User user = resolveOrProvisionUser(provider, accessToken, userInfo, config, frontEndLogin); + + return login(request, response, provider, accessToken, user); + } + + /** + * Issue the dotCMS login cookie for an already resolved OAuth user. + */ + public User login(final HttpServletRequest request, + final HttpServletResponse response, + final OAuthProvider provider, + final String accessToken, + final User user) { + final boolean loggedIn = APILocator.getLoginServiceAPI().doCookieLogin( + PublicEncryptionFactory.encryptString(user.getUserId()), request, response); + + if (loggedIn) { + Try.run(() -> request.changeSessionId()) + .onFailure(e -> Logger.debug(OAuthHelper.class, + "changeSessionId() unsupported or failed: " + e.getMessage())); + final HttpSession session = request.getSession(false); + if (session != null) { + session.setAttribute(com.liferay.portal.util.WebKeys.USER_ID, user.getUserId()); + session.setAttribute(com.liferay.portal.util.WebKeys.USER, user); + session.setAttribute(OAuthConstants.SESSION_ACCESS_TOKEN, accessToken); + session.setAttribute(OAuthConstants.SESSION_PROVIDER_TYPE, provider.getProviderType()); + } + SecurityLogger.logInfo(OAuthHelper.class, + new Date() + ": Successful OAuth login for " + user.getEmailAddress() + + " via " + provider.getProviderType() + " from " + request.getRemoteAddr()); + } else { + throw new DotRuntimeException("doCookieLogin failed for OAuth user " + user.getEmailAddress()); + } + + return user; + } + + /** + * Resolve (or JIT-provision) a dotCMS {@link User} from an OAuth/OIDC userinfo + * payload, applying system + extra + provider-sourced roles. Does not touch the + * HTTP session or issue any cookie — it is reusable from both the browser + * callback flow (which then wraps it with {@code doCookieLogin}) and the + * stateless SPA token-exchange endpoint (which then issues a dotCMS JWT). + * + * @throws DotRuntimeException when the payload is empty / missing required + * claims or the resolved user is not active. + */ + public User resolveOrProvisionUser(final OAuthProvider provider, + final String accessToken, + final Map userInfo, + final OAuthAppConfig config, + final boolean frontEndLogin) throws DotDataException { + return resolveOrProvisionUser(provider, accessToken, userInfo, null, config, frontEndLogin); + } + + /** + * Variant that also accepts the verified id_token claim set (OIDC). Those claims — + * not the unsigned userinfo response — are authoritative for deciding whether the + * asserted email may be trusted to match an existing dotCMS account. Browser-flow + * callers must enforce that the userinfo subject equals the id_token subject before + * calling this; the exchange flow passes the verified claims as {@code userInfo}. + */ + public User resolveOrProvisionUser(final OAuthProvider provider, + final String accessToken, + final Map userInfo, + final Map verifiedClaims, + final OAuthAppConfig config, + final boolean frontEndLogin) throws DotDataException { + + if (userInfo == null || userInfo.isEmpty()) { + throw new DotRuntimeException("OAuth userinfo response was empty — cannot authenticate"); + } + + final String email = getEmail(userInfo, config); + final String subject = resolveSubject(userInfo); + + if (!UtilMethods.isSet(email) && !UtilMethods.isSet(subject)) { + throw new DotRuntimeException("OAuth userinfo response contained neither a usable email nor a subject claim"); + } + + // The external id must be DETERMINISTIC across logins — a per-login value would + // make the externalId lookup miss every time and re-attempt createUser with the + // same email (DuplicateUserException on the second login). When the IdP exposes + // no subject-style claim at all, a verified email is the only stable identity we + // can key on; an unverified email is not safe to trust, so we refuse instead. + final boolean emailVerified = isEmailVerified(userInfo, verifiedClaims); + final String effectiveSubject; + if (UtilMethods.isSet(subject)) { + effectiveSubject = subject; + } else if (emailVerified) { + effectiveSubject = "email:" + email.toLowerCase(); + } else { + throw new DotRuntimeException( + "OAuth userinfo response did not contain a subject-style claim (tried: " + + String.join(", ", SUBJECT_CLAIM_FALLBACKS) + + ") and the email is not verified — refusing to authenticate because no" + + " stable user identity can be established"); + } + + // Namespace the IdP subject with provider type + verified issuer so two + // trusted IdPs that emit the same "sub" can never collide on one user row. + // The iss claim is authoritative for the exchange flow (verified id_token); + // for the browser SSO flow the userinfo endpoint typically omits iss, so we + // fall back to the configured issuerUrl. + final boolean hashUserId = config == null || config.hashUserId; + final String issuer = str(userInfo, "iss"); + final String effectiveIssuer = UtilMethods.isSet(issuer) ? issuer + : (config != null ? config.issuerUrl : null); + final String externalId = namespacedSubject(provider, effectiveSubject, effectiveIssuer, hashUserId); + + User user = resolveUser(email, emailVerified, externalId, provider, effectiveSubject, effectiveIssuer); + + if (user == null) { + if (config != null && !config.autoProvision) { + throw new DotRuntimeException( + "Auto-provisioning is disabled and no matching dotCMS user was found for this OAuth identity"); + } + user = createUser(externalId, email, userInfo, config); + } else { + updateUserProfileFromClaims(user, userInfo, config); + } + + if (!user.isActive()) { + throw new DotRuntimeException("OAuth user " + user.getEmailAddress() + " is not active"); + } + + applyBuildRolesStrategy(user, provider, accessToken, userInfo, config, frontEndLogin); + + // Reload the user so the caller sees the roles just assigned by + // applyBuildRolesStrategy. Without this, isBackendUser()/isFrontendUser() may + // read stale role state immediately after login. + final String userId = user.getUserId(); + return Try.of(() -> APILocator.getUserAPI() + .loadUserById(userId, APILocator.systemUser(), false)) + .getOrElse(user); + } + + private User resolveUser(final String email, + final boolean emailVerified, + final String externalId, + final OAuthProvider provider, + final String subject, + final String issuer) { + // Email may only match an EXISTING account when the IdP asserts it is verified. + // Otherwise an IdP (or a user-editable profile) could claim an arbitrary address + // and take over an account it doesn't own — the issuer-namespaced subject is the + // trustworthy identity key, so an unverified email falls through to it instead. + if (emailVerified && UtilMethods.isSet(email)) { + final User u = Try.of(() -> APILocator.getUserAPI().loadByUserByEmail(email, APILocator.systemUser(), false)) + .getOrNull(); + if (u != null) { + return u; + } + } + if (!UtilMethods.isSet(externalId)) { + return null; + } + for (final String candidate : externalIdCandidates(externalId, provider, subject, issuer)) { + final User u = Try.of(() -> APILocator.getUserAPI().loadUserById(candidate)).getOrNull(); + if (u != null) { + return u; + } + } + return null; + } + + /** + * Claims tried, in order, to establish the IdP-side user identity. OIDC providers + * always send {@code sub}; plain OAuth2 providers commonly expose {@code id} + * (Facebook, GitHub), {@code user_id}, or {@code oid} (Azure AD Graph) instead. + */ + private static final List SUBJECT_CLAIM_FALLBACKS = List.of("sub", "id", "user_id", "oid"); + + private static String resolveSubject(final Map userInfo) { + for (final String claim : SUBJECT_CLAIM_FALLBACKS) { + final String value = str(userInfo, claim); + if (UtilMethods.isSet(value)) { + return value; + } + } + return null; + } + + /** + * Namespace the IdP subject with provider type AND verified issuer so two + * trusted IdPs that emit the same {@code sub} can never collide. The issuer + * is sanitized to keep the raw id free of {@code :} and {@code /}. When + * {@code hashUserId} is true (default) the full string is SHA-256 hashed. + * The subject must be set — callers guarantee a deterministic identity key. + */ + private static String namespacedSubject(final OAuthProvider provider, + final String subject, + final String issuer, + final boolean hashUserId) { + if (!UtilMethods.isSet(subject)) { + throw new DotRuntimeException("Cannot build an OAuth external id without a subject"); + } + final String providerType = provider == null || provider.getProviderType() == null + ? "unknown" : provider.getProviderType(); + final String issuerSegment = UtilMethods.isSet(issuer) + ? "_" + sanitizeForId(issuer) : ""; + final String raw = "oauth_" + providerType + issuerSegment + "_" + subject; + return hashUserId ? hashIt(raw) : raw; + } + + private static String sanitizeForId(final String issuer) { + return issuer.replaceAll("^https?://", "") + .replaceAll("[^a-zA-Z0-9._-]", "_") + .replaceAll("_+$", ""); + } + + /** + * Build the list of external-id forms to try for an existing-user lookup. The first + * element is the primary id (issuer-namespaced); the rest include the pre-issuer + * legacy forms so existing users aren't stranded after the namespace change. + */ + private static List externalIdCandidates(final String primary, + final OAuthProvider provider, + final String subject, + final String issuer) { + if (!UtilMethods.isSet(subject)) { + return Collections.singletonList(primary); + } + final String providerType = provider == null || provider.getProviderType() == null + ? "unknown" : provider.getProviderType(); + final LinkedHashSet ordered = new LinkedHashSet<>(); + ordered.add(primary); + + // Current format with issuer (hashed and unhashed) + if (UtilMethods.isSet(issuer)) { + final String withIssuer = "oauth_" + providerType + "_" + sanitizeForId(issuer) + "_" + subject; + ordered.add(withIssuer); + ordered.add(hashIt(withIssuer)); + } + + // Legacy formats (without issuer) for backward compatibility + final String underscore = "oauth_" + providerType + "_" + subject; + ordered.add(underscore); + ordered.add(hashIt(underscore)); + final String colon = "oauth:" + providerType + ":" + subject; + ordered.add(colon); + return new ArrayList<>(ordered); + } + + private static String hashIt(final String token) { + try { + final String hashed = Encryptor.Hashing.sha256() + .append(token.getBytes(StandardCharsets.UTF_8)) + .buildUnixHash(); + return org.apache.commons.lang3.StringUtils.abbreviate( + hashed, Config.getIntProperty("dotcms.user.id.maxlength", 100)); + } catch (final NoSuchAlgorithmException e) { + // SHA-256 is mandated by the JCA spec; if it's missing the JVM is broken and + // there is no sensible fallback for the auth layer. + throw new DotRuntimeException("SHA-256 unavailable for OAuth user id hashing", e); + } + } + + /** + * Orchestrate per-login role sync: wipe existing roles (for strategies that call + * for it), then reapply the layers configured by the resolved strategy. Mirrors + * {@code SAMLHelper.addRoles} so OAuth/OIDC users behave the same way SAML users + * do on repeat logins — IdP-side group removal actually takes effect in dotCMS. + */ + private void applyBuildRolesStrategy(final User user, + final OAuthProvider provider, + final String accessToken, + final Map userInfo, + final OAuthAppConfig config, + final boolean frontEndLogin) { + final BuildRolesStrategy strategy = BuildRolesStrategy.resolve( + config == null ? null : config.buildRolesStrategy); + + if (strategy == BuildRolesStrategy.NONE) { + Logger.debug(this, () -> "OAUTH_BUILD_ROLES_STRATEGY=NONE — leaving user roles untouched"); + return; + } + + // Serialize the remove + reapply block per user so concurrent logins for the + // same user never observe the "wiped but not yet reapplied" intermediate state. + // Intrinsic-monitor lock on a per-user sentinel is enough — the block runs + // DB-bound work for well under a second and we hold nothing else inside it. + final Object userLock; + try { + userLock = ROLE_SYNC_LOCKS.get(user.getUserId(), Object::new); + } catch (final ExecutionException e) { + throw new DotRuntimeException("Failed to acquire role sync lock", e); + } + synchronized (userLock) { + // Remove all existing roles before reapplying, unless the strategy is STATICADD + // (additive / legacy behavior). Matches SAMLHelper.addRoles exactly. + if (strategy != BuildRolesStrategy.STATICADD) { + Try.run(() -> APILocator.getRoleAPI().removeAllRolesFromUser(user)) + .onFailure(e -> Logger.warn(this, + "Could not remove existing roles from OAuth user " + user.getUserId() + + " before reapplying: " + e.getMessage())); + } + + switch (strategy) { + case ALL: + case STATICADD: + applySystemRoles(user, config, frontEndLogin); + applyExtraRoles(user, config); + applyProviderGroups(user, provider, accessToken, userInfo, config); + break; + case IDP: + // IdP-only: skip the logged-in / back-end baseline, add provider groups only. + applyProviderGroups(user, provider, accessToken, userInfo, config); + break; + case STATICONLY: + // Static-only: system + extra roles, ignore whatever groups the IdP claims. + applySystemRoles(user, config, frontEndLogin); + applyExtraRoles(user, config); + break; + default: + break; + } + } + } + + private User createUser(final String externalId, final String email, + final Map userInfo, final OAuthAppConfig config) + throws DotDataException { + final String userId = UtilMethods.isSet(externalId) ? externalId : UUIDGenerator.generateUuid(); + final String firstName = claimValue(userInfo, config == null ? null : config.firstNameClaim, FIRST_NAME_CLAIMS, "unknown"); + final String lastName = claimValue(userInfo, config == null ? null : config.lastNameClaim, LAST_NAME_CLAIMS, "unknown"); + + try { + final User user = APILocator.getUserAPI().createUser(userId, email); + user.setNickName(firstName); + user.setFirstName(firstName); + user.setLastName(lastName); + user.setActive(true); + user.setCreateDate(new Date()); + user.setPassword(PasswordFactoryProxy.generateHash( + UUIDGenerator.generateUuid() + "/" + UUIDGenerator.generateUuid())); + user.setPasswordEncrypted(true); + APILocator.getUserAPI().save(user, APILocator.systemUser(), false); + Logger.info(this, "Created OAuth user: " + email + " (" + userId + ")"); + return user; + } catch (final DotSecurityException | PasswordException e) { + throw new DotDataException("Failed creating OAuth user " + email + ": " + e.getMessage(), e); + } + } + + private void updateUserProfileFromClaims(final User user, final Map userInfo, + final OAuthAppConfig config) throws DotDataException { + boolean changed = false; + final String firstName = claimValue(userInfo, config == null ? null : config.firstNameClaim, FIRST_NAME_CLAIMS, null); + if (UtilMethods.isSet(firstName) && !firstName.equals(user.getFirstName())) { + user.setFirstName(firstName); + user.setNickName(firstName); + changed = true; + } + final String lastName = claimValue(userInfo, config == null ? null : config.lastNameClaim, LAST_NAME_CLAIMS, null); + if (UtilMethods.isSet(lastName) && !lastName.equals(user.getLastName())) { + user.setLastName(lastName); + changed = true; + } + if (!changed) { + return; + } + try { + APILocator.getUserAPI().save(user, APILocator.systemUser(), false); + } catch (final DotSecurityException e) { + throw new DotDataException("Failed updating OAuth user profile " + + user.getUserId() + ": " + e.getMessage(), e); + } + } + + private void applySystemRoles(final User user, final OAuthAppConfig config, final boolean frontEndLogin) { + final boolean addBackend = config != null && config.enableBackend; + final boolean addFrontend = config != null && config.enableFrontend; + + if (addBackend) { + addRoleIfPresent(user, Try.of(() -> APILocator.getRoleAPI().loadBackEndUserRole()).getOrNull()); + } + if (addFrontend) { + addRoleIfPresent(user, Try.of(() -> APILocator.getRoleAPI().loadLoggedinSiteRole()).getOrNull()); + } + if (!addBackend && !addFrontend) { + final Role fallback = frontEndLogin + ? Try.of(() -> APILocator.getRoleAPI().loadLoggedinSiteRole()).getOrNull() + : Try.of(() -> APILocator.getRoleAPI().loadBackEndUserRole()).getOrNull(); + addRoleIfPresent(user, fallback); + } + } + + private void addRoleIfPresent(final User user, final Role role) { + if (role == null) return; + Try.run(() -> APILocator.getRoleAPI().addRoleToUser(role, user)) + .onFailure(e -> Logger.warn(this, "Could not assign system role: " + e.getMessage())); + } + + private void applyExtraRoles(final User user, final OAuthAppConfig config) { + if (config == null || config.extraRoles == null) { + return; + } + for (final String roleKey : config.extraRoles) { + addRoleByKey(user, roleKey); + } + } + + private void applyProviderGroups(final User user, + final OAuthProvider provider, + final String accessToken, + final Map userInfo, + final OAuthAppConfig config) { + final Collection groups = Try.of(() -> provider.getGroups(accessToken, userInfo)) + .getOrElse(java.util.Collections.emptyList()); + if (groups.isEmpty()) { + Logger.info(this, "OAuth provider returned no groups for " + user.getEmailAddress() + + " — check the dotAuth App's groupsClaim and that the IdP actually emits it in the id_token / userinfo"); + return; + } + Logger.info(this, "OAuth provider returned groups for " + user.getEmailAddress() + ": " + groups); + final Map mappings = parseGroupMappings(config); + for (final String group : groups) { + final String roleKey = mappings.getOrDefault(group, group); + addRoleByKey(user, roleKey); + } + } + + @SuppressWarnings("unchecked") + private static Map parseGroupMappings(final OAuthAppConfig config) { + if (config == null || !UtilMethods.isSet(config.groupMappingsJson)) { + return java.util.Collections.emptyMap(); + } + try { + final List> list = com.dotcms.rest.api.v1.DotObjectMapperProvider + .getInstance().getDefaultObjectMapper() + .readValue(config.groupMappingsJson, List.class); + final Map result = new java.util.HashMap<>(); + for (final Map entry : list) { + final String idpGroup = entry.get("idpGroup"); + final String dotcmsRole = entry.get("dotcmsRole"); + if (UtilMethods.isSet(idpGroup) && UtilMethods.isSet(dotcmsRole)) { + result.put(idpGroup, dotcmsRole); + } + } + return result; + } catch (final Exception e) { + Logger.warn(OAuthHelper.class, "Failed to parse groupMappings config: " + e.getMessage()); + return java.util.Collections.emptyMap(); + } + } + + private void addRoleByKey(final User user, final String roleKey) { + if (!UtilMethods.isSet(roleKey)) { + return; + } + try { + final Role role = APILocator.getRoleAPI().loadRoleByKey(roleKey); + if (role == null) { + Logger.info(this, "OAuth group '" + roleKey + "' has no matching dotCMS role (case-sensitive lookup) — skipping for " + user.getEmailAddress()); + return; + } + if (!APILocator.getRoleAPI().doesUserHaveRole(user, role)) { + APILocator.getRoleAPI().addRoleToUser(role, user); + } + } catch (final DotDataException e) { + Logger.warn(this, "Could not assign role '" + roleKey + "' to " + user.getEmailAddress() + ": " + e.getMessage()); + } + } + + private static String getEmail(final Map userInfo, final OAuthAppConfig config) { + final String email = claimValue(userInfo, config == null ? null : config.emailClaim, EMAIL_CLAIMS, null); + return UtilMethods.isValidEmail(email) ? email : null; + } + + /** + * Whether the IdP asserts {@code email_verified}. The verified id_token claims take + * precedence over the unsigned userinfo response when both are present. + */ + private static boolean isEmailVerified(final Map userInfo, + final Map verifiedClaims) { + return claimsAssertEmailVerified(verifiedClaims) || claimsAssertEmailVerified(userInfo); + } + + private static boolean claimsAssertEmailVerified(final Map claims) { + if (claims == null) { + return false; + } + final Object value = claims.get("email_verified"); + if (value instanceof Boolean) { + return (Boolean) value; + } + return value != null && "true".equalsIgnoreCase(value.toString()); + } + + private static String claimValue(final Map userInfo, + final String configuredClaim, + final String[] fallbackKeys, + final String defaultValue) { + if (UtilMethods.isSet(configuredClaim)) { + final String v = str(userInfo, configuredClaim); + if (UtilMethods.isSet(v)) { + return v; + } + } + for (final String key : fallbackKeys) { + final String v = str(userInfo, key); + if (UtilMethods.isSet(v)) { + return v; + } + } + return defaultValue; + } + + private static String str(final Map userInfo, final String key) { + final Object v = userInfo.get(key); + return v == null ? null : v.toString(); + } +} diff --git a/dotCMS/src/main/java/com/dotcms/auth/providers/oauth/OAuthSsrfGuard.java b/dotCMS/src/main/java/com/dotcms/auth/providers/oauth/OAuthSsrfGuard.java new file mode 100644 index 000000000000..39352fdce829 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/auth/providers/oauth/OAuthSsrfGuard.java @@ -0,0 +1,80 @@ +package com.dotcms.auth.providers.oauth; + +import com.dotmarketing.util.Config; +import com.dotmarketing.util.Logger; +import com.dotmarketing.util.SecurityLogger; +import com.dotmarketing.util.UtilMethods; +import java.net.InetAddress; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.UnknownHostException; +import java.util.Set; + +/** + * Shared SSRF guard for OAuth/OIDC URL validation. + * Checks whether a hostname resolves to a private, loopback, + * link-local, site-local, or multicast address. + */ +public final class OAuthSsrfGuard { + + private static final Set ALLOWED_SCHEMES = Set.of("https", "http"); + + private OAuthSsrfGuard() {} + + public static boolean isInternalHost(final String host) { + try { + final InetAddress[] addresses = InetAddress.getAllByName(host); + for (final InetAddress addr : addresses) { + if (addr.isAnyLocalAddress() + || addr.isLoopbackAddress() + || addr.isLinkLocalAddress() + || addr.isSiteLocalAddress() + || addr.isMulticastAddress()) { + return true; + } + } + return false; + } catch (final UnknownHostException e) { + Logger.debug(OAuthSsrfGuard.class, + "SSRF guard skipped for unresolvable host '" + host + "': " + e.getMessage()); + return false; + } + } + + /** + * Validate a URL for safe server-side fetching: scheme must be HTTP(S), + * HTTPS is required unless {@code OAUTH_ALLOW_INSECURE_URLS=true}, and + * the host must not resolve to a private/internal address. + * + * @return {@code null} if the URL is safe; a rejection reason string otherwise. + */ + public static String validateUrl(final String url) { + if (!UtilMethods.isSet(url)) { + return "URL is required"; + } + final URI uri; + try { + uri = new URI(url); + } catch (final URISyntaxException e) { + return "not a valid URI"; + } + final String scheme = uri.getScheme() == null ? "" : uri.getScheme().toLowerCase(); + if (!ALLOWED_SCHEMES.contains(scheme)) { + return "scheme '" + scheme + "' not allowed"; + } + final boolean allowInsecure = Config.getBooleanProperty("OAUTH_ALLOW_INSECURE_URLS", false); + if ("http".equals(scheme) && !allowInsecure) { + return "http:// URLs require OAUTH_ALLOW_INSECURE_URLS=true"; + } + final String host = uri.getHost(); + if (!UtilMethods.isSet(host)) { + return "URL is missing a host"; + } + if (!allowInsecure && isInternalHost(host)) { + SecurityLogger.logInfo(OAuthSsrfGuard.class, + "URL rejected: host '" + host + "' resolves to an internal address"); + return "host resolves to an internal/private address"; + } + return null; + } +} diff --git a/dotCMS/src/main/java/com/dotcms/auth/providers/oauth/OAuthWebInterceptor.java b/dotCMS/src/main/java/com/dotcms/auth/providers/oauth/OAuthWebInterceptor.java new file mode 100644 index 000000000000..08c7e5c4768f --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/auth/providers/oauth/OAuthWebInterceptor.java @@ -0,0 +1,520 @@ +package com.dotcms.auth.providers.oauth; + +import com.dotcms.auth.AuthAccessDeniedUtil; +import com.dotcms.auth.providers.oauth.provider.GenericOAuth2Provider; +import com.dotcms.auth.providers.oauth.provider.OAuthCrypto; +import com.dotcms.auth.providers.oauth.provider.OAuthProvider; +import com.dotcms.auth.providers.oauth.provider.OIDCProvider; +import com.dotcms.filters.interceptor.Result; +import com.dotcms.filters.interceptor.WebInterceptor; +import com.dotmarketing.business.APILocator; +import com.dotmarketing.util.Config; +import com.dotmarketing.util.Logger; +import com.dotmarketing.util.SecurityLogger; +import com.dotmarketing.util.UtilMethods; +import com.dotmarketing.util.WebKeys; +import com.google.common.collect.ImmutableList; +import com.liferay.portal.model.User; +import com.liferay.portal.util.PortalUtil; +import io.vavr.control.Try; +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpSession; + +/** + * Single consolidated interceptor that handles the OAuth 2.0 / OIDC flow: + *

    + *
  • Login redirect when an unauthenticated user hits a protected URL
  • + *
  • Callback at {@code /api/v1/oauth/callback} — exchanges code, logs user in
  • + *
  • Logout — revokes the token and (optionally) redirects to provider logout
  • + *
+ *

+ * Kept as one class (instead of the plugin's three) because the routing is URL-based; + * splitting gains nothing and costs readability. + */ +public class OAuthWebInterceptor implements WebInterceptor { + + private static final long serialVersionUID = 1L; + + private static final List BACK_END_URLS = + ImmutableList.copyOf(OAuthConstants.BACK_END_URLS); + private static final List FRONT_END_URLS = + ImmutableList.copyOf(OAuthConstants.FRONT_END_URLS); + private static final String[] ALLOWED_URL_FRAGMENTS = Config.getStringArrayProperty( + "OAUTH_LOGIN_ALLOWED_URLS", OAuthConstants.DEFAULT_ALLOWED_URL_FRAGMENTS); + private static final List ALLOWED_URL_FRAGMENTS_LIST = Arrays.asList(ALLOWED_URL_FRAGMENTS); + + private final OAuthHelper oauthHelper = new OAuthHelper(); + + @Override + public String[] getFilters() { + // Register interest in back-end, front-end, callback, and logout URLs. + return ImmutableList.builder() + .addAll(BACK_END_URLS) + .addAll(FRONT_END_URLS) + .add(OAuthConstants.CALLBACK_PATH) + .add(OAuthConstants.LOGOUT_PATHS) + .build() + .toArray(new String[0]); + } + + @Override + public Result intercept(final HttpServletRequest request, final HttpServletResponse response) throws IOException { + + final String uri = request.getRequestURI(); + + if (uri.startsWith(OAuthConstants.CALLBACK_PATH)) { + return handleCallback(request, response); + } + if (isLogoutPath(uri)) { + return handleLogout(request, response); + } + return handleLoginRequired(request, response); + } + + // ---------- login redirect ---------- + + private Result handleLoginRequired(final HttpServletRequest request, + final HttpServletResponse response) throws IOException { + + final Optional cfgOpt = OAuthAppConfig.config(request); + if (cfgOpt.isEmpty()) { + return Result.NEXT; + } + final OAuthAppConfig config = cfgOpt.get(); + + // Avoid request.getSession() (no-arg) here — that creates a fresh session on every + // hit, including scanner/bot traffic that never reaches the redirect branch below. + // Only materialize a session when we have something real to store or read. + final HttpSession existingSession = request.getSession(false); + + // ?native=false clears the bypass — nothing to remove if no session exists yet. + if (Boolean.FALSE.toString().equalsIgnoreCase(request.getParameter(OAuthConstants.PARAM_NATIVE)) + && existingSession != null) { + existingSession.removeAttribute(OAuthConstants.SESSION_NATIVE_LOGIN); + } + // ?native=true sets the bypass for this session — explicit opt-in, create if needed. + if (Boolean.TRUE.toString().equalsIgnoreCase(request.getParameter(OAuthConstants.PARAM_NATIVE))) { + request.getSession().setAttribute(OAuthConstants.SESSION_NATIVE_LOGIN, Boolean.TRUE); + return Result.NEXT; + } + if (existingSession != null + && Boolean.TRUE.equals(existingSession.getAttribute(OAuthConstants.SESSION_NATIVE_LOGIN))) { + return Result.NEXT; + } + + // Normalize the URI once so path-matching can't be tricked with "/./" or "/../" segments. + final String uri = normalizePath(request.getRequestURI()); + if (ALLOWED_URL_FRAGMENTS_LIST.stream().anyMatch(uri::contains)) { + return Result.NEXT; + } + + final boolean isFrontEndLogin = config.enableFrontend + && FRONT_END_URLS.stream().anyMatch(uri::startsWith); + final boolean isBackEndLogin = config.enableBackend + && BACK_END_URLS.stream().anyMatch(uri::startsWith); + + if (!isFrontEndLogin && !isBackEndLogin) { + return Result.NEXT; + } + + final com.liferay.portal.model.User existingUser = PortalUtil.getUser(request); + if (existingUser != null) { + final boolean hasRequiredRole = + (isBackEndLogin && AuthAccessDeniedUtil.hasRequiredRole(existingUser, false)) + || (isFrontEndLogin && AuthAccessDeniedUtil.hasRequiredRole(existingUser, true)); + if (hasRequiredRole) { + return Result.NEXT; + } + // Authenticated via SSO but lacks the required role — show a 403 rather + // than redirecting to the IdP (which would loop since the user IS authenticated). + AuthAccessDeniedUtil.sendNoAccessPage(response, existingUser); + return Result.SKIP_NO_CHAIN; + } + + final OAuthProvider provider = Try.of(() -> buildProvider(config)) + .onFailure(e -> Logger.warn(this, "Unable to build OAuth provider: " + e.getMessage())) + .getOrNull(); + if (provider == null) { + return Result.NEXT; + } + + final String callbackUrl = computeCallbackUrl(request, config); + if (callbackUrl == null) { + // §1.6: fail open to NEXT rather than redirecting to a callback URL derived + // from an attacker-controlled Host header. Admins see a SECURITY-logged warning. + return Result.NEXT; + } + + // Remember the URI we want to return to after login, and whether this was a front-end login. + AuthAccessDeniedUtil.setNoCacheHeaders(response); + final HttpSession session = request.getSession(); + session.setAttribute(OAuthConstants.SESSION_ORIGINAL_REQUEST, originalRequestUri(request)); + session.setAttribute(OAuthConstants.SESSION_FRONT_END_LOGIN, isFrontEndLogin); + + // state (CSRF), nonce (OIDC id_token replay guard), PKCE verifier (auth-code interception guard). + final String state = OAuthCrypto.newState(); + final String nonce = config.isOidc() ? OAuthCrypto.newNonce() : null; + final String codeVerifier = OAuthCrypto.newPkceVerifier(); + final String codeChallenge = OAuthCrypto.pkceChallengeS256(codeVerifier); + + session.setAttribute(OAuthConstants.SESSION_STATE, state); + session.setAttribute(OAuthConstants.SESSION_CODE_VERIFIER, codeVerifier); + if (nonce != null) { + session.setAttribute(OAuthConstants.SESSION_NONCE, nonce); + } else { + session.removeAttribute(OAuthConstants.SESSION_NONCE); + } + + final String authUrl = provider.buildAuthorizationUrl( + state, nonce, codeChallenge, callbackUrl, config.scopes); + Logger.info(this, "OAuth: redirecting to provider authorization URL"); + response.sendRedirect(authUrl); + return Result.SKIP_NO_CHAIN; + } + + // ---------- callback ---------- + + private Result handleCallback(final HttpServletRequest request, + final HttpServletResponse response) throws IOException { + + AuthAccessDeniedUtil.setNoCacheHeaders(response); + + final User existingUser = PortalUtil.getUser(request); + if (existingUser != null) { + final HttpSession existingSession = request.getSession(false); + final boolean frontEndLogin = existingSession != null + && Boolean.TRUE.equals(existingSession.getAttribute(OAuthConstants.SESSION_FRONT_END_LOGIN)); + if (AuthAccessDeniedUtil.hasRequiredRole(existingUser, frontEndLogin)) { + response.sendRedirect(frontEndLogin ? "/" : "/dotAdmin/"); + } else { + AuthAccessDeniedUtil.sendNoAccessPage(response, existingUser); + } + return Result.SKIP_NO_CHAIN; + } + + final Optional cfgOpt = OAuthAppConfig.config(request); + if (cfgOpt.isEmpty()) { + response.sendRedirect("/?error=oauth+not+configured"); + return Result.SKIP_NO_CHAIN; + } + final OAuthAppConfig config = cfgOpt.get(); + + final String code = request.getParameter(OAuthConstants.PARAM_CODE); + final String state = request.getParameter(OAuthConstants.PARAM_STATE); + if (!UtilMethods.isSet(code)) { + response.sendRedirect("/?error=oauth+no+code"); + return Result.SKIP_NO_CHAIN; + } + + // CSRF protection: state must match what we stored before redirect. Constant-time + // comparison so a timing channel can't leak the expected value byte-by-byte. + final HttpSession session = request.getSession(); + final String expectedState = (String) session.getAttribute(OAuthConstants.SESSION_STATE); + if (!UtilMethods.isSet(expectedState) || !UtilMethods.isSet(state) + || !MessageDigest.isEqual( + expectedState.getBytes(StandardCharsets.UTF_8), + state.getBytes(StandardCharsets.UTF_8))) { + Logger.warn(this, "OAuth callback state mismatch — possible CSRF, rejecting"); + response.sendRedirect("/?error=oauth+state+mismatch"); + return Result.SKIP_NO_CHAIN; + } + session.removeAttribute(OAuthConstants.SESSION_STATE); + + try { + final OAuthProvider provider = buildProvider(config); + final String callbackUrl = computeCallbackUrl(request, config); + if (callbackUrl == null) { + response.sendRedirect("/?error=oauth+callback+url+missing"); + return Result.SKIP_NO_CHAIN; + } + final String codeVerifier = (String) session.getAttribute(OAuthConstants.SESSION_CODE_VERIFIER); + session.removeAttribute(OAuthConstants.SESSION_CODE_VERIFIER); + final String expectedNonce = (String) session.getAttribute(OAuthConstants.SESSION_NONCE); + session.removeAttribute(OAuthConstants.SESSION_NONCE); + + final Map tokenResponse = provider.exchangeCodeForToken(code, codeVerifier, callbackUrl); + final String accessToken = (String) tokenResponse.get("access_token"); + final String idToken = (String) tokenResponse.get("id_token"); + + // For OIDC, the id_token MUST be present and MUST validate. Refusing to proceed here + // means a man-in-the-middle or rogue IdP can't log a user in by intercepting just the code. + final Map verifiedClaims; + if (config.isOidc()) { + if (!UtilMethods.isSet(idToken)) { + throw new com.dotmarketing.exception.DotRuntimeException( + "OIDC token response did not include an id_token — refusing to authenticate"); + } + verifiedClaims = provider.validateIdTokenAndExtractClaims(idToken, expectedNonce); + OIDCProvider.validateAtHash(idToken, accessToken); + } else { + verifiedClaims = null; + } + + final boolean frontEndLogin = Boolean.TRUE.equals(session.getAttribute(OAuthConstants.SESSION_FRONT_END_LOGIN)); + final Map userInfo = provider.getUserInfo(accessToken); + + // The id_token is the only signed, audience- and nonce-bound artifact in this flow; + // the userinfo response is not. OIDC Core §5.3.2 requires the userinfo subject to + // equal the id_token subject — enforce it so a substituted access token can't pivot + // provisioning onto a different identity than the one we cryptographically verified. + if (verifiedClaims != null) { + final Object idSub = verifiedClaims.get("sub"); + final Object userInfoSub = userInfo == null ? null : userInfo.get("sub"); + if (idSub != null && userInfoSub != null + && !String.valueOf(idSub).equals(String.valueOf(userInfoSub))) { + throw new com.dotmarketing.exception.DotRuntimeException( + "OIDC userinfo subject does not match the verified id_token subject — refusing to authenticate"); + } + } + + final User user = oauthHelper.resolveOrProvisionUser( + provider, accessToken, userInfo, verifiedClaims, config, frontEndLogin); + + if (!AuthAccessDeniedUtil.hasRequiredRole(user, frontEndLogin)) { + AuthAccessDeniedUtil.sendNoAccessPage(response, user); + return Result.SKIP_NO_CHAIN; + } + + oauthHelper.login(request, response, provider, accessToken, user); + + final HttpSession postLoginSession = request.getSession(false); + if (postLoginSession != null && UtilMethods.isSet(idToken)) { + postLoginSession.setAttribute(OAuthConstants.SESSION_ID_TOKEN, idToken); + } + + final String originalUri = (String) session.getAttribute(OAuthConstants.SESSION_ORIGINAL_REQUEST); + session.removeAttribute(OAuthConstants.SESSION_ORIGINAL_REQUEST); + + final String fallback = user.isBackendUser() ? "/dotAdmin/" : "/"; + final String redirectTo = sanitizeRedirect(originalUri, fallback); + response.sendRedirect(redirectTo); + return Result.SKIP_NO_CHAIN; + } catch (final Exception e) { + Logger.error(this, "OAuth callback failed: " + e.getMessage(), e); + Try.run(() -> APILocator.getLoginServiceAPI().doActionLogout(request, response)) + .onFailure(ex -> Logger.debug(this, "cleanup logout failed: " + ex.getMessage())); + final HttpSession staleSession = request.getSession(false); + if (staleSession != null) { + Try.run(staleSession::invalidate); + } + response.sendRedirect("/?error=oauth+callback+failed"); + return Result.SKIP_NO_CHAIN; + } + } + + // ---------- logout ---------- + + private Result handleLogout(final HttpServletRequest request, + final HttpServletResponse response) throws IOException { + + final HttpSession session = request.getSession(false); + final Optional cfgOpt = OAuthAppConfig.config(request); + + // No session or no config — fall through to the normal logout chain + // (LogoutResource / LogoutWebInterceptor). + if (session == null || cfgOpt.isEmpty()) { + return Result.NEXT; + } + + final OAuthAppConfig config = cfgOpt.get(); + AuthAccessDeniedUtil.setNoCacheHeaders(response); + + final String accessToken = (String) session.getAttribute(OAuthConstants.SESSION_ACCESS_TOKEN); + final String idToken = (String) session.getAttribute(OAuthConstants.SESSION_ID_TOKEN); + + // Only take over logout for sessions that were actually created via OAuth (OAuthHelper.login + // stamps the access token and provider type). Native (?native=true), basic-credential and + // SAML sessions must keep the standard logout contract (LogoutResource JSON / + // LogoutWebInterceptor show-logout.jsp) and must never be bounced to the IdP. + if (!UtilMethods.isSet(accessToken) + && !UtilMethods.isSet(idToken) + && session.getAttribute(OAuthConstants.SESSION_PROVIDER_TYPE) == null) { + return Result.NEXT; + } + + String providerLogoutUrl = null; + + try { + final OAuthProvider provider = buildProvider(config); + if (UtilMethods.isSet(accessToken)) { + provider.revokeToken(accessToken); + } + providerLogoutUrl = provider.getLogoutUrl(idToken, null).orElse(null); + } catch (final Exception e) { + Logger.warn(this, "OAuth logout failed during token revocation / logout URL resolution: " + e.getMessage()); + } + + session.removeAttribute(OAuthConstants.SESSION_ACCESS_TOKEN); + session.removeAttribute(OAuthConstants.SESSION_ID_TOKEN); + session.removeAttribute(OAuthConstants.SESSION_PROVIDER_TYPE); + + return doCoreLogout(request, response, providerLogoutUrl); + } + + /** + * OAuth logout short-circuits the interceptor chain (returns SKIP_NO_CHAIN) because the + * 302 to the IdP end-session endpoint must be the final response. LogoutWebInterceptor + * would otherwise forward to show-logout.jsp, which conflicts with the provider logout. + * We replicate its audit log here so the logout event is still captured. + */ + private Result doCoreLogout(final HttpServletRequest request, + final HttpServletResponse response, + final String providerLogoutUrl) throws IOException { + final User user = PortalUtil.getUser(request); + Try.run(() -> APILocator.getLoginServiceAPI().doActionLogout(request, response)) + .onFailure(e -> Logger.warn(this, "doActionLogout failed: " + e.getMessage())); + if (user != null) { + SecurityLogger.logInfo(OAuthWebInterceptor.class, + "User " + user.getFullName() + " (" + user.getUserId() + + ") has logged out via OAuth from IP: " + request.getRemoteAddr()); + } + if (!response.isCommitted()) { + response.sendRedirect(UtilMethods.isSet(providerLogoutUrl) ? providerLogoutUrl : "/"); + } + return Result.SKIP_NO_CHAIN; + } + + // ---------- helpers ---------- + + private OAuthProvider buildProvider(final OAuthAppConfig config) { + if (config.isOidc()) { + return new OIDCProvider(config.issuerUrl, config.clientId, config.clientSecret, + config.groupsClaim, config.groupsUrl); + } + return new GenericOAuth2Provider(config.clientId, config.clientSecret, + config.authorizationUrl, config.tokenUrl, config.userinfoUrl, + config.revocationUrl, config.logoutUrl, + config.groupsClaim, config.groupsUrl); + } + + /** + * Resolve the callback URL the IdP should redirect to. An explicit {@code callbackUrl} acts + * as an override (the callback path is appended if missing). When it is blank — the default — + * the base URL is derived from the request (see {@link #baseUrlFromRequest}), the same way SAML + * derives its endpoints. Deriving per-request is required for a global config that serves + * multiple sites, where each site's callback host differs; a single stored value can't cover + * them all. The servlet container (RemoteIpValve) makes scheme/serverName/serverPort + * proxy-aware, and the IdP's registered redirect_uri allowlist is the authoritative guard + * against a spoofed Host header. + */ + private String computeCallbackUrl(final HttpServletRequest request, final OAuthAppConfig config) { + final String base; + if (UtilMethods.isSet(config.callbackUrl)) { + base = config.callbackUrl.replaceAll("/+$", ""); + } else { + base = baseUrlFromRequest(request); + if (base == null) { + SecurityLogger.logInfo(OAuthWebInterceptor.class, + "OAuth callbackUrl is not configured and the request base URL could not be derived"); + return null; + } + } + return base.endsWith(OAuthConstants.CALLBACK_PATH) + ? base + : base + OAuthConstants.CALLBACK_PATH; + } + + /** + * Build {@code scheme://host[:port]} from the request. The port is omitted for the scheme's + * default (443 for https, 80 for http) and only appended when non-standard (e.g. + * {@code localhost:8080}) — so the derived redirect_uri matches what is registered at the IdP, + * which is configured without an explicit port for standard ports. Returns null if + * scheme/host are absent. + */ + static String baseUrlFromRequest(final HttpServletRequest request) { + final String scheme = request.getScheme(); + final String host = request.getServerName(); + if (!UtilMethods.isSet(scheme) || !UtilMethods.isSet(host)) { + return null; + } + final int port = request.getServerPort(); + final boolean defaultPort = port <= 0 + || ("https".equalsIgnoreCase(scheme) && port == 443) + || ("http".equalsIgnoreCase(scheme) && port == 80); + return defaultPort ? scheme + "://" + host : scheme + "://" + host + ":" + port; + } + + static String originalRequestUri(final HttpServletRequest request) { + // Prefer dotCMS's server-side request cache: core's login-required logic + // (HTMLPageAssetAPIImpl, CMSUrlUtil, the back-end login interceptor) stores the original + // page URI in REDIRECT_AFTER_LOGIN before bouncing an anonymous user to login. This is the + // trusted, server-set destination — so post-login we return to the page the user actually + // wanted (e.g. /members/) rather than the login URL itself, which isn't a real page and 404s. + final HttpSession session = request.getSession(false); + if (session != null) { + final Object redirectAfterLogin = session.getAttribute(WebKeys.REDIRECT_AFTER_LOGIN); + if (redirectAfterLogin instanceof String && UtilMethods.isSet((String) redirectAfterLogin)) { + return (String) redirectAfterLogin; + } + } + // Fallback: a login-trigger URL may carry the destination in the referrer param. + final String referrer = request.getParameter(OAuthConstants.PARAM_REFERRER); + if (UtilMethods.isSet(referrer)) { + return referrer; + } + final Object forward = request.getAttribute(javax.servlet.RequestDispatcher.FORWARD_REQUEST_URI); + if (forward != null) { + return forward.toString(); + } + // sanitizeRedirect() guards whatever is returned here against open-redirect when it is used. + final String q = request.getQueryString(); + return q == null ? request.getRequestURI() : request.getRequestURI() + "?" + q; + } + + /** + * Normalize the request URI so path-matching cannot be bypassed with "/./" or "/../" + * segments. Falls back to the raw path if the URI is malformed. + */ + private static String normalizePath(final String uri) { + if (uri == null) { + return ""; + } + try { + final String normalized = new URI(uri).normalize().getPath(); + return normalized == null ? uri : normalized; + } catch (final URISyntaxException e) { + return uri; + } + } + + /** + * Sanitize a post-auth redirect target. Only accept same-origin relative paths that + * start with a single "/" — reject protocol-relative ("//evil"), absolute URLs, + * backslashes, and anything with a scheme/authority delimiter. + */ + static String sanitizeRedirect(final String candidate, final String fallback) { + if (candidate == null || candidate.isEmpty()) { + return fallback; + } + final int queryIndex = candidate.indexOf('?'); + final String path = queryIndex < 0 ? candidate : candidate.substring(0, queryIndex); + if (!candidate.startsWith("/") + || candidate.startsWith("//") + || candidate.startsWith("/\\") + || candidate.contains("\\") + || path.contains(":")) { + return fallback; + } + return candidate; + } + + private static boolean isLogoutPath(final String uri) { + for (final String path : OAuthConstants.LOGOUT_PATHS) { + if (uri.startsWith(path)) { + return true; + } + } + return false; + } + +} diff --git a/dotCMS/src/main/java/com/dotcms/auth/providers/oauth/provider/GenericOAuth2Provider.java b/dotCMS/src/main/java/com/dotcms/auth/providers/oauth/provider/GenericOAuth2Provider.java new file mode 100644 index 000000000000..eebc05619fb0 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/auth/providers/oauth/provider/GenericOAuth2Provider.java @@ -0,0 +1,239 @@ +package com.dotcms.auth.providers.oauth.provider; + +import static com.dotcms.auth.providers.oauth.OAuthConstants.PROVIDER_TYPE_OAUTH2; + +import com.dotcms.http.CircuitBreakerUrl; +import com.dotcms.rest.api.v1.DotObjectMapperProvider; +import com.dotmarketing.exception.DotRuntimeException; +import com.dotmarketing.util.Logger; +import com.dotmarketing.util.UtilMethods; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.collect.ImmutableMap; +import io.vavr.control.Try; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.TreeMap; + +/** + * Generic OAuth 2.0 provider for non-OIDC identity providers (e.g. Facebook). + * All endpoint URLs must be supplied explicitly; nothing is discovered. + */ +public class GenericOAuth2Provider implements OAuthProvider { + + private static final ObjectMapper MAPPER = DotObjectMapperProvider.getInstance().getDefaultObjectMapper(); + + private final String clientId; + private final char[] clientSecret; + private final String authorizationUrl; + private final String tokenUrl; + private final String userinfoUrl; + private final String revocationUrl; + private final String logoutUrl; + private final String groupsClaim; + private final String groupsUrl; + + public GenericOAuth2Provider(final String clientId, + final char[] clientSecret, + final String authorizationUrl, + final String tokenUrl, + final String userinfoUrl, + final String revocationUrl, + final String logoutUrl, + final String groupsClaim, + final String groupsUrl) { + if (!UtilMethods.isSet(authorizationUrl) || !UtilMethods.isSet(tokenUrl) || !UtilMethods.isSet(userinfoUrl)) { + throw new DotRuntimeException("GenericOAuth2Provider requires authorizationUrl, tokenUrl, and userinfoUrl"); + } + this.clientId = clientId; + this.clientSecret = clientSecret == null ? new char[0] : clientSecret; + this.authorizationUrl = authorizationUrl; + this.tokenUrl = tokenUrl; + this.userinfoUrl = userinfoUrl; + this.revocationUrl = revocationUrl; + this.logoutUrl = logoutUrl; + this.groupsClaim = groupsClaim; + this.groupsUrl = groupsUrl; + } + + @Override + public String buildAuthorizationUrl(final String state, + final String nonce, + final String codeChallenge, + final String callbackUrl, + final String scope) { + // Plain OAuth2 doesn't support nonce — only OIDC does. We accept the parameter to keep + // the interface uniform but silently ignore it here. + final String effectiveScope = UtilMethods.isSet(scope) ? scope : ""; + // Pin response_mode=query so a hostile or misconfigured IdP cannot flip to + // form_post (which would POST the auth code through a browser-rendered form + // and sidestep our query-string callback parser). + final StringBuilder sb = new StringBuilder(authorizationUrl) + .append(authorizationUrl.contains("?") ? "&" : "?") + .append("response_type=code") + .append("&response_mode=query") + .append("&client_id=").append(urlEncode(clientId)) + .append("&redirect_uri=").append(urlEncode(callbackUrl)); + if (UtilMethods.isSet(effectiveScope)) { + sb.append("&scope=").append(urlEncode(effectiveScope)); + } + sb.append("&state=").append(urlEncode(state)); + if (UtilMethods.isSet(codeChallenge)) { + sb.append("&code_challenge=").append(urlEncode(codeChallenge)) + .append("&code_challenge_method=S256"); + } + return sb.toString(); + } + + @Override + public Map exchangeCodeForToken(final String code, + final String codeVerifier, + final String callbackUrl) { + final StringBuilder body = new StringBuilder() + .append("grant_type=authorization_code") + .append("&code=").append(urlEncode(code)) + .append("&redirect_uri=").append(urlEncode(callbackUrl)); + if (UtilMethods.isSet(codeVerifier)) { + body.append("&code_verifier=").append(urlEncode(codeVerifier)); + } + try { + final CircuitBreakerUrl.Response resp = CircuitBreakerUrl.builder() + .setUrl(tokenUrl) + .setMethod(CircuitBreakerUrl.Method.POST) + .setRawData(body.toString()) + .setHeaders(ImmutableMap.of( + "Authorization", OAuthCrypto.basicAuthHeader(clientId, clientSecret), + "Accept", "application/json", + "Content-Type", "application/x-www-form-urlencoded")) + .setTimeout(10000) + .build() + .doResponse(); + if (resp.getStatusCode() < 200 || resp.getStatusCode() >= 300) { + throw new DotRuntimeException("OAuth2 token exchange failed: HTTP " + resp.getStatusCode()); + } + final Map json = MAPPER.readValue(resp.getResponse(), new TypeReference>() {}); + if (json.get("access_token") == null) { + throw new DotRuntimeException("OAuth2 token response missing access_token"); + } + return json; + } catch (final DotRuntimeException e) { + throw e; + } catch (final Exception e) { + throw new DotRuntimeException("OAuth2 token exchange failed: " + e.getMessage(), e); + } + } + + @Override + public Map getUserInfo(final String accessToken) { + try { + final CircuitBreakerUrl.Response resp = CircuitBreakerUrl.builder() + .setUrl(userinfoUrl) + .setMethod(CircuitBreakerUrl.Method.GET) + .setHeaders(ImmutableMap.of( + "Authorization", "Bearer " + accessToken, + "Accept", "application/json")) + .setTimeout(5000) + .build() + .doResponse(); + if (resp.getStatusCode() < 200 || resp.getStatusCode() >= 300) { + throw new DotRuntimeException("OAuth2 userinfo failed: HTTP " + resp.getStatusCode()); + } + final Map raw = MAPPER.readValue(resp.getResponse(), new TypeReference>() {}); + final Map ci = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + ci.putAll(raw); + return ci; + } catch (final DotRuntimeException e) { + throw e; + } catch (final Exception e) { + throw new DotRuntimeException("OAuth2 userinfo call failed: " + e.getMessage(), e); + } + } + + @Override + public Collection getGroups(final String accessToken, final Map userInfo) { + if (UtilMethods.isSet(groupsClaim) && userInfo != null && userInfo.containsKey(groupsClaim)) { + return toStringList(userInfo.get(groupsClaim)); + } + if (UtilMethods.isSet(groupsUrl)) { + return Try.of(() -> fetchGroupsFromUrl(accessToken)).getOrElse(Collections.emptyList()); + } + return Collections.emptyList(); + } + + private Collection fetchGroupsFromUrl(final String accessToken) throws Exception { + final CircuitBreakerUrl.Response resp = CircuitBreakerUrl.builder() + .setUrl(groupsUrl) + .setMethod(CircuitBreakerUrl.Method.GET) + .setHeaders(ImmutableMap.of( + "Authorization", "Bearer " + accessToken, + "Accept", "application/json")) + .setTimeout(5000) + .build() + .doResponse(); + if (resp.getStatusCode() < 200 || resp.getStatusCode() >= 300) { + Logger.warn(this, "OAuth2 groups endpoint returned HTTP " + resp.getStatusCode()); + return Collections.emptyList(); + } + final Object parsed = MAPPER.readValue(resp.getResponse(), Object.class); + if (parsed instanceof List) { + return toStringList(parsed); + } + if (parsed instanceof Map) { + return toStringList(((Map) parsed).get("groups")); + } + return Collections.emptyList(); + } + + @Override + public void revokeToken(final String accessToken) { + if (!UtilMethods.isSet(revocationUrl) || !UtilMethods.isSet(accessToken)) { + return; + } + try { + final String body = "token=" + urlEncode(accessToken); + CircuitBreakerUrl.builder() + .setUrl(revocationUrl) + .setMethod(CircuitBreakerUrl.Method.POST) + .setRawData(body) + .setHeaders(ImmutableMap.of( + "Authorization", OAuthCrypto.basicAuthHeader(clientId, clientSecret), + "Content-Type", "application/x-www-form-urlencoded")) + .setTimeout(5000) + .build() + .doResponse(); + } catch (final Exception e) { + Logger.warn(this, "OAuth2 token revocation failed: " + e.getMessage()); + } + } + + @Override + public Optional getLogoutUrl(final String idToken, final String postLogoutRedirectUri) { + return UtilMethods.isSet(logoutUrl) ? Optional.of(logoutUrl) : Optional.empty(); + } + + @Override + public String getProviderType() { + return PROVIDER_TYPE_OAUTH2; + } + + @SuppressWarnings("unchecked") + private static Collection toStringList(final Object value) { + if (value instanceof Collection) { + final Collection c = (Collection) value; + return c.stream().filter(java.util.Objects::nonNull).map(Object::toString).collect(java.util.stream.Collectors.toList()); + } + return Collections.emptyList(); + } + + private static String urlEncode(final String s) { + if (s == null) { + return ""; + } + return URLEncoder.encode(s, StandardCharsets.UTF_8); + } +} diff --git a/dotCMS/src/main/java/com/dotcms/auth/providers/oauth/provider/OAuthCrypto.java b/dotCMS/src/main/java/com/dotcms/auth/providers/oauth/provider/OAuthCrypto.java new file mode 100644 index 000000000000..8acb4d10fb72 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/auth/providers/oauth/provider/OAuthCrypto.java @@ -0,0 +1,88 @@ +package com.dotcms.auth.providers.oauth.provider; + +import com.dotmarketing.exception.DotRuntimeException; +import com.dotmarketing.util.UtilMethods; +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.Arrays; +import java.util.Base64; +import java.util.UUID; + +/** + * Crypto helpers shared across OAuth providers: HTTP Basic auth header construction + * that keeps the client secret in a {@code char[]} for as long as possible, and + * PKCE (RFC 7636) verifier/challenge generation. + */ +public final class OAuthCrypto { + + private static final SecureRandom RANDOM = new SecureRandom(); + + private OAuthCrypto() {} + + /** + * Build an {@code Authorization: Basic} header value from a client id and char[] secret. + * The intermediate byte buffer is zeroed after encoding so the secret spends minimal time + * as a heap object the GC cannot reclaim. + */ + public static String basicAuthHeader(final String clientId, final char[] clientSecret) { + if (!UtilMethods.isSet(clientId) || clientSecret == null) { + throw new DotRuntimeException("client_id and client_secret are required to build Basic auth"); + } + final CharBuffer charBuffer = CharBuffer.allocate( + clientId.length() + 1 + clientSecret.length); + charBuffer.put(clientId).put(':').put(clientSecret); + charBuffer.flip(); + final ByteBuffer byteBuffer = StandardCharsets.UTF_8.encode(charBuffer); + final byte[] encoded = new byte[byteBuffer.remaining()]; + byteBuffer.get(encoded); + try { + final String header = "Basic " + Base64.getEncoder().encodeToString(encoded); + return header; + } finally { + Arrays.fill(encoded, (byte) 0); + if (byteBuffer.hasArray()) { + Arrays.fill(byteBuffer.array(), (byte) 0); + } + Arrays.fill(charBuffer.array(), '\0'); + } + } + + /** + * Generate an RFC 7636 PKCE {@code code_verifier}. 64 random bytes encoded as + * base64url (unpadded) — well within the 43..128 char range the spec requires. + */ + public static String newPkceVerifier() { + final byte[] bytes = new byte[64]; + RANDOM.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + /** + * Derive the S256 code_challenge for a PKCE verifier: + * {@code base64url(sha256(verifier))}. + */ + public static String pkceChallengeS256(final String verifier) { + try { + final MessageDigest sha256 = MessageDigest.getInstance("SHA-256"); + final byte[] digest = sha256.digest(verifier.getBytes(StandardCharsets.US_ASCII)); + return Base64.getUrlEncoder().withoutPadding().encodeToString(digest); + } catch (final NoSuchAlgorithmException e) { + // SHA-256 is mandatory on every JVM — any failure here is a platform bug. + throw new DotRuntimeException("SHA-256 unavailable for PKCE challenge derivation", e); + } + } + + /** Generate a random OAuth {@code state} value. */ + public static String newState() { + return UUID.randomUUID().toString(); + } + + /** Generate a random OIDC {@code nonce} value. */ + public static String newNonce() { + return UUID.randomUUID().toString(); + } +} diff --git a/dotCMS/src/main/java/com/dotcms/auth/providers/oauth/provider/OAuthProvider.java b/dotCMS/src/main/java/com/dotcms/auth/providers/oauth/provider/OAuthProvider.java new file mode 100644 index 000000000000..39355755b2ab --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/auth/providers/oauth/provider/OAuthProvider.java @@ -0,0 +1,91 @@ +package com.dotcms.auth.providers.oauth.provider; + +import java.util.Collection; +import java.util.Map; +import java.util.Optional; + +/** + * Generic OAuth 2.0 / OpenID Connect provider abstraction. + * Implementations translate between dotCMS and an identity provider without + * depending on a specific provider library (no Scribe). + */ +public interface OAuthProvider { + + /** + * Build the authorization URL the browser should redirect to. + * @param state CSRF-guard state value + * @param nonce OIDC nonce (may be null for plain OAuth2) + * @param codeChallenge PKCE S256 code_challenge (may be null if PKCE is disabled) + * @param callbackUrl where the IdP will redirect after auth + * @param scope requested scopes, space-separated + */ + String buildAuthorizationUrl(String state, + String nonce, + String codeChallenge, + String callbackUrl, + String scope); + + /** + * Exchange an authorization code for the provider token response. Returns the + * parsed JSON (keyed by snake_case field name, e.g. {@code access_token}, {@code id_token}). + * @param code authorization code returned by the IdP + * @param codeVerifier PKCE verifier matching the challenge used in the authorization URL + * @param callbackUrl same callbackUrl that was used in the authorization URL + */ + Map exchangeCodeForToken(String code, + String codeVerifier, + String callbackUrl); + + /** + * Validate an {@code id_token} issued for this provider and return the verified + * subject claim. OIDC-only; plain OAuth2 providers do not issue id tokens and + * the default implementation throws {@link UnsupportedOperationException}. + * @param idToken the raw JWT as returned from the token endpoint + * @param expectedNonce the nonce that was sent in the authorization URL + */ + default String validateIdTokenAndExtractSubject(final String idToken, + final String expectedNonce) { + throw new UnsupportedOperationException( + "id_token validation is only supported for OIDC providers"); + } + + /** + * Validate an {@code id_token} issued for this provider and return the verified + * claim set as a case-insensitive map, keyed by claim name (e.g. {@code sub}, + * {@code email}, {@code given_name}). OIDC-only; plain OAuth2 providers do not + * issue id tokens and the default implementation throws + * {@link UnsupportedOperationException}. + *

+ * Same validation contract as {@link #validateIdTokenAndExtractSubject}: + * signature (JWKS), {@code iss}, {@code aud}, {@code exp}, and {@code nonce} + * are all verified before claims are returned. Used by the stateless + * token-exchange endpoint which needs more than just {@code sub}. + * + * @param idToken the raw JWT as returned from the token endpoint + * @param expectedNonce the nonce that was sent in the authorization URL + */ + default Map validateIdTokenAndExtractClaims(final String idToken, + final String expectedNonce) { + throw new UnsupportedOperationException( + "id_token validation is only supported for OIDC providers"); + } + + /** Call the userinfo endpoint with the token. Returns a case-insensitive map of claims. */ + Map getUserInfo(String accessToken); + + /** Optional: fetch groups from a separate endpoint when groups are not in userinfo. */ + default Collection getGroups(String accessToken, Map userInfo) { + return java.util.Collections.emptyList(); + } + + /** Optional: revoke the access token server-side. No-op if unsupported. */ + default void revokeToken(String accessToken) {} + + /** Optional: provider-side logout redirect URL (RP-initiated logout for OIDC). */ + default Optional getLogoutUrl(String idToken, String postLogoutRedirectUri) { + return Optional.empty(); + } + + /** Short identifier used for logging and session attribute tagging. */ + String getProviderType(); +} diff --git a/dotCMS/src/main/java/com/dotcms/auth/providers/oauth/provider/OIDCProvider.java b/dotCMS/src/main/java/com/dotcms/auth/providers/oauth/provider/OIDCProvider.java new file mode 100644 index 000000000000..c4b3fbb2e0e0 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/auth/providers/oauth/provider/OIDCProvider.java @@ -0,0 +1,680 @@ +package com.dotcms.auth.providers.oauth.provider; + +import static com.dotcms.auth.providers.oauth.OAuthConstants.DISCOVERY_PATH; +import static com.dotcms.auth.providers.oauth.OAuthConstants.PROVIDER_TYPE_OIDC; + +import com.dotcms.auth.providers.oauth.OAuthSsrfGuard; +import com.dotcms.http.CircuitBreakerUrl; +import com.dotcms.rest.api.v1.DotObjectMapperProvider; +import com.dotmarketing.exception.DotRuntimeException; +import com.dotmarketing.util.Config; +import com.dotmarketing.util.Logger; +import com.dotmarketing.util.UtilMethods; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.collect.ImmutableMap; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.jwk.source.JWKSource; +import com.nimbusds.jose.jwk.source.JWKSourceBuilder; +import com.nimbusds.jose.proc.JWSVerificationKeySelector; +import com.nimbusds.jose.proc.SecurityContext; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import com.nimbusds.jwt.proc.ConfigurableJWTProcessor; +import com.nimbusds.jwt.proc.DefaultJWTProcessor; +import io.vavr.control.Try; +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.TreeMap; +import java.util.concurrent.ConcurrentHashMap; + +/** + * OIDC provider that auto-configures endpoints from {@code /.well-known/openid-configuration}. + *

+ * Configure with the issuer URL (e.g. {@code https://accounts.google.com}); discovery + * populates authorization, token, userinfo, revocation and end-session endpoints. + */ +public class OIDCProvider implements OAuthProvider { + + private static final ObjectMapper MAPPER = DotObjectMapperProvider.getInstance().getDefaultObjectMapper(); + + /** + * Cache of parsed {@code .well-known/openid-configuration} documents keyed by issuer URL. + * TTL is controlled by {@code OAUTH_DISCOVERY_CACHE_TTL_SECONDS} (default 15 min). + * Avoids a network round-trip on every OIDCProvider construction (previously hit during + * every login redirect, every callback, and every ViewTool access). + */ + private static final ConcurrentHashMap DISCOVERY_CACHE = new ConcurrentHashMap<>(); + + private static final ConcurrentHashMap> JWKS_CACHE = new ConcurrentHashMap<>(); + + /** + * Cap on outbound IdP response bodies (discovery, token, userinfo, groups, revocation). + * Bounds heap use against a malicious/compromised IdP that streams an oversized body + * during authentication. Mirrors the {@code setMaxResponseBytes} guard the admin + * discovery/metadata proxies in {@code DotAuthResource} already apply. + */ + private static final int MAX_IDP_RESPONSE_BYTES = + Config.getIntProperty("OAUTH_IDP_MAX_RESPONSE_BYTES", 1024 * 1024); + + /** + * Safe {@code id_token} signing algorithms. Asymmetric only (RSA or EC) — symmetric + * {@code HS*} algs rely on a shared secret, and {@code none} is an explicit attacker + * vector. The effective allow-list for a given IdP is this set intersected with the + * algorithms advertised in discovery's {@code id_token_signing_alg_values_supported}. + */ + private static final Set SAFE_ID_TOKEN_ALGS = Collections.unmodifiableSet( + new LinkedHashSet<>(java.util.Arrays.asList( + JWSAlgorithm.RS256, JWSAlgorithm.RS384, JWSAlgorithm.RS512, + JWSAlgorithm.ES256, JWSAlgorithm.ES384, JWSAlgorithm.ES512, + JWSAlgorithm.PS256, JWSAlgorithm.PS384, JWSAlgorithm.PS512))); + + private static final class CachedDiscovery { + final Map document; + final long expiresAtMs; + CachedDiscovery(final Map document, final long expiresAtMs) { + this.document = document; + this.expiresAtMs = expiresAtMs; + } + boolean isFresh() { + return System.currentTimeMillis() < expiresAtMs; + } + } + + private final String issuerUrl; + private final String clientId; + private final char[] clientSecret; + + private final String authorizationEndpoint; + private final String tokenEndpoint; + private final String userinfoEndpoint; + private final String revocationEndpoint; + private final String endSessionEndpoint; + private final String jwksUri; + + private final String groupsClaim; + private final String groupsUrl; + + private final Set idTokenAllowedAlgs; + + public OIDCProvider(final String issuerUrl, + final String clientId, + final char[] clientSecret, + final String groupsClaim, + final String groupsUrl) { + if (!UtilMethods.isSet(issuerUrl)) { + throw new DotRuntimeException("OIDC issuerUrl is required"); + } + this.issuerUrl = issuerUrl.endsWith("/") ? issuerUrl.substring(0, issuerUrl.length() - 1) : issuerUrl; + this.clientId = clientId; + this.clientSecret = clientSecret == null ? new char[0] : clientSecret; + this.groupsClaim = groupsClaim; + this.groupsUrl = groupsUrl; + + final Map discovery = discover(this.issuerUrl); + verifyDiscoveryIssuer(discovery, this.issuerUrl); + this.authorizationEndpoint = validateDiscoveredUrl( + (String) discovery.get("authorization_endpoint"), "authorization_endpoint", true); + this.tokenEndpoint = validateDiscoveredUrl( + (String) discovery.get("token_endpoint"), "token_endpoint", true); + this.userinfoEndpoint = validateDiscoveredUrl( + (String) discovery.get("userinfo_endpoint"), "userinfo_endpoint", false); + this.revocationEndpoint = validateDiscoveredUrl( + (String) discovery.get("revocation_endpoint"), "revocation_endpoint", false); + this.endSessionEndpoint = validateDiscoveredUrl( + (String) discovery.get("end_session_endpoint"), "end_session_endpoint", false); + this.jwksUri = validateDiscoveredUrl( + (String) discovery.get("jwks_uri"), "jwks_uri", true); + + if (!UtilMethods.isSet(this.authorizationEndpoint) || !UtilMethods.isSet(this.tokenEndpoint)) { + throw new DotRuntimeException("OIDC discovery at " + this.issuerUrl + DISCOVERY_PATH + + " did not return the required authorization_endpoint / token_endpoint"); + } + this.idTokenAllowedAlgs = resolveAllowedIdTokenAlgs(discovery); + } + + /** + * Pin the {@code id_token} verification to the algorithms the IdP published in + * discovery, intersected with {@link #SAFE_ID_TOKEN_ALGS}. Without this pin, a + * hostile IdP could sign tokens with whatever alg it chose and the RP would + * happily validate them — defeating the point of verified discovery. If the + * intersection is empty (IdP advertises only unsafe algs, or omits the list), + * fall back to {@link JWSAlgorithm#RS256}, which OIDC Core §2 requires every + * provider to support. + */ + private static Set resolveAllowedIdTokenAlgs(final Map discovery) { + final Object published = discovery.get("id_token_signing_alg_values_supported"); + if (!(published instanceof Collection)) { + return Collections.singleton(JWSAlgorithm.RS256); + } + final Set allowed = new HashSet<>(); + for (final Object entry : (Collection) published) { + if (entry == null) { + continue; + } + final JWSAlgorithm parsed = JWSAlgorithm.parse(entry.toString()); + if (SAFE_ID_TOKEN_ALGS.contains(parsed)) { + allowed.add(parsed); + } + } + if (allowed.isEmpty()) { + return Collections.singleton(JWSAlgorithm.RS256); + } + return Collections.unmodifiableSet(allowed); + } + + /** + * Resolve the OIDC discovery document for an issuer, caching the parsed JSON per issuer + * for the configured TTL. Stale or missing entries trigger a fresh fetch. + */ + static Map discover(final String issuerUrl) { + final long ttlMs = Config.getIntProperty("OAUTH_DISCOVERY_CACHE_TTL_SECONDS", 900) * 1000L; + return DISCOVERY_CACHE.compute(issuerUrl, (key, cached) -> { + if (cached != null && cached.isFresh()) { + return cached; + } + final Map fresh = fetchDiscovery(key + DISCOVERY_PATH); + return new CachedDiscovery(fresh, System.currentTimeMillis() + ttlMs); + }).document; + } + + private static void verifyDiscoveryIssuer(final Map discovery, + final String expectedIssuer) { + final Object issuer = discovery.get("issuer"); + if (!UtilMethods.isSet(issuer == null ? null : issuer.toString())) { + return; + } + final String normalizedIssuer = stripTrailingSlash(issuer.toString()); + if (!expectedIssuer.equals(normalizedIssuer)) { + throw new DotRuntimeException("OIDC discovery issuer mismatch: expected '" + + expectedIssuer + "' got '" + issuer + "'"); + } + } + + static String validateDiscoveredUrl(final String url, + final String fieldName, + final boolean required) { + if (!UtilMethods.isSet(url)) { + if (required) { + throw new DotRuntimeException("OIDC discovery missing required " + fieldName); + } + return null; + } + final URI uri; + try { + uri = new URI(url); + } catch (final URISyntaxException e) { + throw new DotRuntimeException("OIDC discovery " + fieldName + + " is not a valid URI: " + e.getMessage(), e); + } + final String scheme = uri.getScheme() == null ? "" : uri.getScheme().toLowerCase(); + if (!"https".equals(scheme) && !"http".equals(scheme)) { + throw new DotRuntimeException("OIDC discovery " + fieldName + + " uses unsupported scheme '" + scheme + "'"); + } + final boolean allowInsecure = Config.getBooleanProperty("OAUTH_ALLOW_INSECURE_URLS", false); + if ("http".equals(scheme) && !allowInsecure) { + throw new DotRuntimeException("OIDC discovery " + fieldName + + " must use https unless OAUTH_ALLOW_INSECURE_URLS=true"); + } + final String host = uri.getHost(); + if (!UtilMethods.isSet(host)) { + throw new DotRuntimeException("OIDC discovery " + fieldName + " is missing a host"); + } + if (!allowInsecure && OAuthSsrfGuard.isInternalHost(host)) { + throw new DotRuntimeException("OIDC discovery " + fieldName + + " resolves to an internal/private address"); + } + return url; + } + + + private static Map fetchDiscovery(final String discoveryUrl) { + try { + final CircuitBreakerUrl.Response resp = CircuitBreakerUrl.builder() + .setUrl(discoveryUrl) + .setMethod(CircuitBreakerUrl.Method.GET) + .setTimeout(5000) + .setMaxResponseBytes(MAX_IDP_RESPONSE_BYTES) + .build() + .doResponse(); + if (resp.getStatusCode() < 200 || resp.getStatusCode() >= 300) { + throw new DotRuntimeException("OIDC discovery failed: HTTP " + resp.getStatusCode() + " from " + discoveryUrl); + } + return MAPPER.readValue(resp.getResponse(), new TypeReference>() {}); + } catch (final DotRuntimeException e) { + throw e; + } catch (final Exception e) { + throw new DotRuntimeException("OIDC discovery failed at " + discoveryUrl + ": " + e.getMessage(), e); + } + } + + @Override + public String buildAuthorizationUrl(final String state, + final String nonce, + final String codeChallenge, + final String callbackUrl, + final String scope) { + final String effectiveScope = UtilMethods.isSet(scope) ? scope : "openid email profile"; + // Pin response_mode=query: keeps the auth code in the URL query-string where our + // callback parser expects it, and prevents a hostile IdP from switching to + // form_post — which would POST the code through the browser and bypass our + // parser entirely. + final StringBuilder sb = new StringBuilder(authorizationEndpoint) + .append(authorizationEndpoint.contains("?") ? "&" : "?") + .append("response_type=code") + .append("&response_mode=query") + .append("&client_id=").append(urlEncode(clientId)) + .append("&redirect_uri=").append(urlEncode(callbackUrl)) + .append("&scope=").append(urlEncode(effectiveScope)) + .append("&state=").append(urlEncode(state)); + if (UtilMethods.isSet(nonce)) { + sb.append("&nonce=").append(urlEncode(nonce)); + } + if (UtilMethods.isSet(codeChallenge)) { + sb.append("&code_challenge=").append(urlEncode(codeChallenge)) + .append("&code_challenge_method=S256"); + } + return sb.toString(); + } + + @Override + public Map exchangeCodeForToken(final String code, + final String codeVerifier, + final String callbackUrl) { + final StringBuilder body = new StringBuilder() + .append("grant_type=authorization_code") + .append("&code=").append(urlEncode(code)) + .append("&redirect_uri=").append(urlEncode(callbackUrl)); + if (UtilMethods.isSet(codeVerifier)) { + body.append("&code_verifier=").append(urlEncode(codeVerifier)); + } + try { + final CircuitBreakerUrl.Response resp = CircuitBreakerUrl.builder() + .setUrl(tokenEndpoint) + .setMethod(CircuitBreakerUrl.Method.POST) + .setRawData(body.toString()) + .setHeaders(ImmutableMap.of( + "Authorization", OAuthCrypto.basicAuthHeader(clientId, clientSecret), + "Accept", "application/json", + "Content-Type", "application/x-www-form-urlencoded")) + .setTimeout(10000) + .setMaxResponseBytes(MAX_IDP_RESPONSE_BYTES) + .build() + .doResponse(); + if (resp.getStatusCode() < 200 || resp.getStatusCode() >= 300) { + throw new DotRuntimeException("OIDC token exchange failed: HTTP " + resp.getStatusCode()); + } + final Map json = MAPPER.readValue(resp.getResponse(), new TypeReference>() {}); + if (json.get("access_token") == null) { + throw new DotRuntimeException("OIDC token response missing access_token"); + } + return json; + } catch (final DotRuntimeException e) { + throw e; + } catch (final Exception e) { + throw new DotRuntimeException("OIDC token exchange failed: " + e.getMessage(), e); + } + } + + /** + * Validate an OIDC {@code id_token} per the core OIDC spec and return the + * verified claim set as a case-insensitive map keyed by claim name. + *

    + *
  • Signature verified against the IdP's JWKS (fetched via {@code jwks_uri})
  • + *
  • {@code alg} is in the allow-list (prevents alg-confusion attacks)
  • + *
  • {@code iss} matches the configured issuer
  • + *
  • {@code aud} contains the configured {@code client_id}
  • + *
  • {@code exp} is in the future
  • + *
  • {@code nonce} matches the {@code expectedNonce} the caller supplies. In the + * browser/interceptor flow that value is the server-generated, session-stored, + * one-time-use nonce (genuine replay protection). In the headless exchange flow + * the caller presents both the {@code id_token} and the {@code nonce}, so this + * check only confirms the two are internally consistent — it is not + * replay protection there; the exchange endpoint guards replay separately via a + * one-time-use consumed-token cache.
  • + *
+ * Only returns once every check has passed. Throws {@link DotRuntimeException} + * on any validation failure. + */ + @Override + public Map validateIdTokenAndExtractClaims(final String idToken, + final String expectedNonce) { + if (!UtilMethods.isSet(idToken)) { + throw new DotRuntimeException("OIDC id_token is missing — cannot validate"); + } + if (!UtilMethods.isSet(jwksUri)) { + throw new DotRuntimeException("OIDC discovery did not provide a jwks_uri; cannot verify id_token"); + } + try { + final SignedJWT jwt = SignedJWT.parse(idToken); + final JWKSource keySource = JWKS_CACHE.computeIfAbsent(jwksUri, uri -> { + try { + final long jwksTtl = Config.getIntProperty("OAUTH_JWKS_CACHE_TTL_SECONDS", 300); + final long jwksRefreshAhead = Config.getIntProperty("OAUTH_JWKS_REFRESH_AHEAD_SECONDS", 30); + return JWKSourceBuilder.create(new URL(uri)) + .cache(jwksTtl * 1000L, jwksRefreshAhead * 1000L) + .rateLimited(false) + .retrying(true) + .build(); + } catch (final MalformedURLException e) { + throw new DotRuntimeException("OIDC jwks_uri is malformed: " + uri, e); + } + }); + final JWSAlgorithm tokenAlg = jwt.getHeader().getAlgorithm() == null + ? null + : JWSAlgorithm.parse(jwt.getHeader().getAlgorithm().getName()); + if (tokenAlg == null || !idTokenAllowedAlgs.contains(tokenAlg)) { + throw new DotRuntimeException("OIDC id_token alg '" + tokenAlg + + "' is not in the allow-list " + idTokenAllowedAlgs + + " — reject to prevent alg-confusion attacks"); + } + final ConfigurableJWTProcessor processor = new DefaultJWTProcessor<>(); + processor.setJWSKeySelector(new JWSVerificationKeySelector<>(idTokenAllowedAlgs, keySource)); + + final JWTClaimsSet claims = processor.process(jwt, null); + + verifyIdTokenClaims(claims, issuerUrl, clientId, expectedNonce); + + // Expose verified claims to callers (case-insensitive to match getUserInfo shape). + final Map ci = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + ci.putAll(claims.getClaims()); + return ci; + } catch (final DotRuntimeException e) { + throw e; + } catch (final Exception e) { + throw new DotRuntimeException("OIDC id_token validation failed: " + e.getMessage(), e); + } + } + + @Override + public String validateIdTokenAndExtractSubject(final String idToken, + final String expectedNonce) { + final Map claims = validateIdTokenAndExtractClaims(idToken, expectedNonce); + return String.valueOf(claims.get("sub")); + } + + /** + * Post-signature claim checks for a parsed id_token: iss / aud / exp / nonce / sub. + * Package-private so the logic can be unit-tested without the JWKS round-trip and + * Nimbus processor setup that the public entry point performs first. + *

+ * iss is compared after trailing-slash normalization on both sides — the configured + * {@code expectedIssuer} has already been normalized in the constructor; the token's + * iss gets the same treatment here so an IdP that emits iss with a slash (some Auth0 + * / Azure B2C tenant variants) doesn't fail validation against an admin config that + * was entered without one, or vice-versa. OIDC Core §2 calls for strict URL equality + * but IdP behavior in the wild is inconsistent. + */ + static void verifyIdTokenClaims(final JWTClaimsSet claims, + final String expectedIssuer, + final String expectedClientId, + final String expectedNonce) { + // iss + final String tokenIss = claims.getIssuer(); + final String normalizedTokenIss = stripTrailingSlash(tokenIss); + if (!expectedIssuer.equals(normalizedTokenIss)) { + throw new DotRuntimeException( + "OIDC id_token iss mismatch: expected '" + expectedIssuer + "' got '" + tokenIss + "'"); + } + // aud — must contain our clientId (aud may be single string or array per spec). + // THIS is the check that makes the token "ours" vs. "just valid somewhere on this IdP". + final List audiences = claims.getAudience(); + if (audiences == null || !audiences.contains(expectedClientId)) { + throw new DotRuntimeException( + "OIDC id_token aud does not contain this client_id; got " + audiences); + } + // azp — OIDC Core §3.1.3.7: when the token carries more than one audience, the + // authorized-party (azp) claim MUST be present and MUST equal our client_id. + // Without this, a token minted for a different relying party that merely lists us + // among several audiences would pass the aud check above (confused-deputy). + if (audiences.size() > 1 && expectedClientId != null) { + final Object azp = claims.getClaim("azp"); + final String azpValue = azp == null ? null : azp.toString(); + if (!UtilMethods.isSet(azpValue) + || !MessageDigest.isEqual( + expectedClientId.getBytes(StandardCharsets.UTF_8), + azpValue.getBytes(StandardCharsets.UTF_8))) { + throw new DotRuntimeException( + "OIDC id_token has multiple audiences but azp is missing or does not " + + "match this client_id"); + } + } + // exp is required by OIDC Core §2 (Nimbus's default verifier treats it as optional). + // Nimbus's DefaultJWTProcessor already rejects expired tokens with a 60s clock-skew + // allowance; mirror that 60s here so this explicit gate never rejects a token it accepted. + if (claims.getExpirationTime() == null + || claims.getExpirationTime().getTime() + 60_000L <= System.currentTimeMillis()) { + throw new DotRuntimeException("OIDC id_token is expired or has no exp claim"); + } + // nonce — constant-time comparison to match the state check in OAuthWebInterceptor + final Object tokenNonce = claims.getClaim("nonce"); + if (!UtilMethods.isSet(expectedNonce) + || tokenNonce == null + || !MessageDigest.isEqual( + expectedNonce.getBytes(StandardCharsets.UTF_8), + tokenNonce.toString().getBytes(StandardCharsets.UTF_8))) { + throw new DotRuntimeException("OIDC id_token nonce mismatch — possible replay"); + } + // sub is required by the spec — downstream provisioning relies on it. + if (!UtilMethods.isSet(claims.getSubject())) { + throw new DotRuntimeException("OIDC id_token missing sub claim"); + } + } + + private static String stripTrailingSlash(final String value) { + return value != null && value.endsWith("/") + ? value.substring(0, value.length() - 1) + : value; + } + + @Override + public Map getUserInfo(final String accessToken) { + if (!UtilMethods.isSet(userinfoEndpoint)) { + throw new DotRuntimeException("OIDC discovery did not provide a userinfo_endpoint"); + } + try { + final CircuitBreakerUrl.Response resp = CircuitBreakerUrl.builder() + .setUrl(userinfoEndpoint) + .setMethod(CircuitBreakerUrl.Method.GET) + .setHeaders(ImmutableMap.of( + "Authorization", "Bearer " + accessToken, + "Accept", "application/json")) + .setTimeout(5000) + .setMaxResponseBytes(MAX_IDP_RESPONSE_BYTES) + .build() + .doResponse(); + if (resp.getStatusCode() < 200 || resp.getStatusCode() >= 300) { + throw new DotRuntimeException("OIDC userinfo failed: HTTP " + resp.getStatusCode()); + } + final Map raw = MAPPER.readValue(resp.getResponse(), new TypeReference>() {}); + // Return a case-insensitive map so downstream lookups don't depend on provider casing. + final Map ci = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + ci.putAll(raw); + return ci; + } catch (final DotRuntimeException e) { + throw e; + } catch (final Exception e) { + throw new DotRuntimeException("OIDC userinfo call failed: " + e.getMessage(), e); + } + } + + @Override + public Collection getGroups(final String accessToken, final Map userInfo) { + // Prefer the claim from userinfo if configured + if (UtilMethods.isSet(groupsClaim) && userInfo != null && userInfo.containsKey(groupsClaim)) { + return toStringList(userInfo.get(groupsClaim)); + } + // Fall back to a separate groups endpoint if configured + if (UtilMethods.isSet(groupsUrl)) { + return Try.of(() -> fetchGroupsFromUrl(accessToken)).getOrElse(Collections.emptyList()); + } + return Collections.emptyList(); + } + + private Collection fetchGroupsFromUrl(final String accessToken) throws Exception { + final CircuitBreakerUrl.Response resp = CircuitBreakerUrl.builder() + .setUrl(groupsUrl) + .setMethod(CircuitBreakerUrl.Method.GET) + .setHeaders(ImmutableMap.of( + "Authorization", "Bearer " + accessToken, + "Accept", "application/json")) + .setTimeout(5000) + .setMaxResponseBytes(MAX_IDP_RESPONSE_BYTES) + .build() + .doResponse(); + if (resp.getStatusCode() < 200 || resp.getStatusCode() >= 300) { + Logger.warn(this, "OIDC groups endpoint returned HTTP " + resp.getStatusCode()); + return Collections.emptyList(); + } + // Accept either a JSON array of strings, or a JSON object with a "groups" array. + final Object parsed = MAPPER.readValue(resp.getResponse(), Object.class); + if (parsed instanceof List) { + return toStringList(parsed); + } + if (parsed instanceof Map) { + return toStringList(((Map) parsed).get("groups")); + } + return Collections.emptyList(); + } + + @Override + public void revokeToken(final String accessToken) { + if (!UtilMethods.isSet(revocationEndpoint) || !UtilMethods.isSet(accessToken)) { + return; + } + try { + final String body = "token=" + urlEncode(accessToken); + CircuitBreakerUrl.builder() + .setUrl(revocationEndpoint) + .setMethod(CircuitBreakerUrl.Method.POST) + .setRawData(body) + .setHeaders(ImmutableMap.of( + "Authorization", OAuthCrypto.basicAuthHeader(clientId, clientSecret), + "Content-Type", "application/x-www-form-urlencoded")) + .setTimeout(5000) + .setMaxResponseBytes(MAX_IDP_RESPONSE_BYTES) + .build() + .doResponse(); + } catch (final Exception e) { + Logger.warn(this, "OIDC token revocation failed: " + e.getMessage()); + } + } + + @Override + public Optional getLogoutUrl(final String idToken, final String postLogoutRedirectUri) { + if (!UtilMethods.isSet(endSessionEndpoint)) { + return Optional.empty(); + } + final StringBuilder query = new StringBuilder(); + if (UtilMethods.isSet(idToken)) { + query.append("id_token_hint=").append(urlEncode(idToken)); + } + if (UtilMethods.isSet(postLogoutRedirectUri)) { + if (query.length() > 0) { + query.append('&'); + } + query.append("post_logout_redirect_uri=").append(urlEncode(postLogoutRedirectUri)); + } + if (query.length() == 0) { + return Optional.of(endSessionEndpoint); + } + return Optional.of(endSessionEndpoint + + (endSessionEndpoint.contains("?") ? "&" : "?") + + query); + } + + @Override + public String getProviderType() { + return PROVIDER_TYPE_OIDC; + } + + /** + * Validate the {@code at_hash} claim in a verified id_token against the access token + * per OIDC Core §3.2.2.9. Call this after {@link #validateIdTokenAndExtractClaims} + * when both an access token and id_token are available from the same token response. + * Skips validation silently only when {@code at_hash} is genuinely absent (it is + * optional in the authorization-code flow). When {@code at_hash} is present + * but cannot be parsed/computed/compared, this fails closed (throws) rather than + * silently degrading the access-token-substitution defense to a no-op. + */ + public static void validateAtHash(final String idToken, final String accessToken) { + if (!UtilMethods.isSet(idToken) || !UtilMethods.isSet(accessToken)) { + return; + } + final Object atHashClaim; + final JWSAlgorithm alg; + try { + final SignedJWT jwt = SignedJWT.parse(idToken); + final JWTClaimsSet claims = jwt.getJWTClaimsSet(); + atHashClaim = claims.getClaim("at_hash"); + alg = jwt.getHeader().getAlgorithm(); + } catch (final Exception e) { + // The same id_token was already parsed and signature-verified upstream, so a + // failure to re-parse here is anomalous — fail closed rather than skip the check. + throw new DotRuntimeException( + "OIDC at_hash validation could not parse the id_token: " + e.getMessage(), e); + } + if (atHashClaim == null) { + // Genuinely absent — at_hash is optional in the code flow, so this is a valid skip. + return; + } + final String atHash = atHashClaim.toString(); + try { + final String hashAlg = resolveHashAlgorithm(alg); + final java.security.MessageDigest md = java.security.MessageDigest.getInstance(hashAlg); + final byte[] fullHash = md.digest(accessToken.getBytes(StandardCharsets.US_ASCII)); + final byte[] leftHalf = java.util.Arrays.copyOf(fullHash, fullHash.length / 2); + final String computed = com.nimbusds.jose.util.Base64URL.encode(leftHalf).toString(); + if (!MessageDigest.isEqual( + atHash.getBytes(StandardCharsets.UTF_8), + computed.getBytes(StandardCharsets.UTF_8))) { + throw new DotRuntimeException( + "OIDC id_token at_hash does not match the access token — possible token substitution"); + } + } catch (final DotRuntimeException e) { + throw e; + } catch (final Exception e) { + // at_hash IS present but we could not verify it — fail closed. + throw new DotRuntimeException( + "OIDC id_token at_hash present but could not be verified: " + e.getMessage(), e); + } + } + + private static String resolveHashAlgorithm(final JWSAlgorithm alg) { + if (alg == null) return "SHA-256"; + final String name = alg.getName(); + if (name.endsWith("384")) return "SHA-384"; + if (name.endsWith("512")) return "SHA-512"; + return "SHA-256"; + } + + @SuppressWarnings("unchecked") + private static Collection toStringList(final Object value) { + if (value instanceof Collection) { + final Collection c = (Collection) value; + return c.stream().filter(java.util.Objects::nonNull).map(Object::toString).collect(java.util.stream.Collectors.toList()); + } + return Collections.emptyList(); + } + + private static String urlEncode(final String s) { + if (s == null) { + return ""; + } + return URLEncoder.encode(s, StandardCharsets.UTF_8); + } +} diff --git a/dotCMS/src/main/java/com/dotcms/auth/providers/oauth/viewtool/OAuthTokenTool.java b/dotCMS/src/main/java/com/dotcms/auth/providers/oauth/viewtool/OAuthTokenTool.java new file mode 100644 index 000000000000..11a0374ae582 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/auth/providers/oauth/viewtool/OAuthTokenTool.java @@ -0,0 +1,196 @@ +package com.dotcms.auth.providers.oauth.viewtool; + +import com.dotcms.auth.providers.oauth.OAuthAppConfig; +import com.dotcms.auth.providers.oauth.provider.OAuthCrypto; +import com.dotcms.http.CircuitBreakerUrl; +import com.dotcms.rest.api.v1.DotObjectMapperProvider; +import com.dotmarketing.util.Logger; +import com.dotmarketing.util.UtilMethods; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.collect.ImmutableMap; +import com.liferay.portal.model.User; +import com.liferay.portal.util.PortalUtil; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import javax.servlet.http.HttpServletRequest; +import org.apache.velocity.tools.view.context.ViewContext; +import org.apache.velocity.tools.view.tools.ViewTool; + +/** + * Velocity helper that fetches a service-to-service access token using the + * {@code client_credentials} grant against the configured OAuth app's token + * endpoint. Tokens are cached in-memory until their (approximate) expiry. + *

+ * Replaces the plugin's {@code ADFSTool} with a provider-agnostic version. + * Registered as {@code $oauthToken} in {@code toolbox.xml}. + */ +public class OAuthTokenTool implements ViewTool { + + private static final ObjectMapper MAPPER = DotObjectMapperProvider.getInstance().getDefaultObjectMapper(); + + private static final Map CACHE = new ConcurrentHashMap<>(); + private static final long DEFAULT_TIMEOUT_MS = 5000L; + + private HttpServletRequest request; + + @Override + public void init(final Object initData) { + if (initData instanceof ViewContext) { + this.request = ((ViewContext) initData).getRequest(); + } + } + + /** + * Fetch a cached client_credentials access token using the dotOAuth app config. + * Returns null when OAuth is not configured or the token exchange fails. + */ + public String getAccessToken() { + return getAccessToken(null); + } + + /** + * Fetch a cached client_credentials access token, optionally narrowing the scope. + * Returns null on error; templates should check for null before use. + *

+ * Access-gated to authenticated backend admin users. The raw client_credentials + * token has the privileges of the dotCMS OAuth app itself (not of any end-user), + * so leaking it to an anonymous frontend template — even by accident (typo in a + * public VTL) — is equivalent to handing out the app secret. Only admins editing + * backend templates should be able to retrieve it. + */ + public String getAccessToken(final String scope) { + if (!isAuthorized()) { + return null; + } + final Optional cfgOpt = OAuthAppConfig.config(request); + if (cfgOpt.isEmpty()) { + return null; + } + final OAuthAppConfig config = cfgOpt.get(); + final String tokenEndpoint = resolveTokenEndpoint(config); + if (!UtilMethods.isSet(tokenEndpoint)) { + Logger.warn(this, "OAuthTokenTool: no token endpoint available; OIDC discovery or manual tokenUrl required"); + return null; + } + + final String effectiveScope = UtilMethods.isSet(scope) ? scope : config.scopes; + final String cacheKey = config.clientId + "|" + tokenEndpoint + "|" + effectiveScope; + final CachedToken cached = CACHE.get(cacheKey); + if (cached != null && !cached.isExpired()) { + return cached.accessToken; + } + + try { + // HTTP Basic (preferred by RFC 6749 §2.3.1) keeps the secret out of the request body + // and avoids materializing it as a String beyond the narrow window inside OAuthCrypto. + final String body = "grant_type=client_credentials" + + (UtilMethods.isSet(effectiveScope) ? "&scope=" + urlEncode(effectiveScope) : ""); + + final CircuitBreakerUrl.Response resp = CircuitBreakerUrl.builder() + .setUrl(tokenEndpoint) + .setMethod(CircuitBreakerUrl.Method.POST) + .setRawData(body) + .setHeaders(ImmutableMap.of( + "Authorization", OAuthCrypto.basicAuthHeader(config.clientId, config.clientSecret), + "Accept", "application/json", + "Content-Type", "application/x-www-form-urlencoded")) + .setTimeout(DEFAULT_TIMEOUT_MS) + .build() + .doResponse(); + + if (resp.getStatusCode() < 200 || resp.getStatusCode() >= 300) { + Logger.warn(this, "OAuthTokenTool: client_credentials exchange returned HTTP " + resp.getStatusCode()); + return null; + } + + final Map json = MAPPER.readValue(resp.getResponse(), new TypeReference>() {}); + final Object token = json.get("access_token"); + if (token == null) { + return null; + } + final long expiresInSeconds = json.get("expires_in") instanceof Number + ? ((Number) json.get("expires_in")).longValue() + : 1800L; // default to 30 min if provider omits expires_in + CACHE.put(cacheKey, new CachedToken(token.toString(), System.currentTimeMillis() + (expiresInSeconds * 1000L))); + return token.toString(); + } catch (final Exception e) { + Logger.warn(this, "OAuthTokenTool: exchange failed: " + e.getMessage()); + return null; + } + } + + private boolean isAuthorized() { + if (request == null) { + return false; + } + final User user = PortalUtil.getUser(request); + if (user == null || user.isAnonymousUser()) { + Logger.debug(this, "OAuthTokenTool: anonymous caller blocked from client_credentials token"); + return false; + } + if (user.isFrontendUser() || !user.isBackendUser()) { + Logger.warn(this, "OAuthTokenTool: non-backend user '" + user.getUserId() + + "' blocked from client_credentials token"); + return false; + } + if (!user.isAdmin()) { + Logger.warn(this, "OAuthTokenTool: non-admin backend user '" + user.getUserId() + + "' blocked from client_credentials token"); + return false; + } + return true; + } + + private String resolveTokenEndpoint(final OAuthAppConfig config) { + if (UtilMethods.isSet(config.tokenUrl)) { + return config.tokenUrl; + } + if (config.isOidc() && UtilMethods.isSet(config.issuerUrl)) { + // Fall back to OIDC discovery. This is a one-shot call per-JVM via the OIDCProvider cache + // upstream; here we re-fetch because the ViewTool does not instantiate an OIDCProvider. + return discoverTokenEndpoint(config.issuerUrl); + } + return null; + } + + private static String discoverTokenEndpoint(final String issuerUrl) { + try { + final String base = issuerUrl.endsWith("/") ? issuerUrl.substring(0, issuerUrl.length() - 1) : issuerUrl; + final CircuitBreakerUrl.Response resp = CircuitBreakerUrl.builder() + .setUrl(base + com.dotcms.auth.providers.oauth.OAuthConstants.DISCOVERY_PATH) + .setMethod(CircuitBreakerUrl.Method.GET) + .setTimeout(DEFAULT_TIMEOUT_MS) + .build() + .doResponse(); + if (resp.getStatusCode() < 200 || resp.getStatusCode() >= 300) { + return null; + } + final Map json = MAPPER.readValue(resp.getResponse(), new TypeReference>() {}); + return (String) json.get("token_endpoint"); + } catch (final Exception e) { + Logger.warn(OAuthTokenTool.class, "OIDC discovery for ViewTool token fetch failed: " + e.getMessage()); + return null; + } + } + + private static String urlEncode(final String s) { + return s == null ? "" : URLEncoder.encode(s, StandardCharsets.UTF_8); + } + + private static final class CachedToken { + final String accessToken; + final long expiresAt; + CachedToken(final String accessToken, final long expiresAt) { + this.accessToken = accessToken; + this.expiresAt = expiresAt; + } + boolean isExpired() { + // Refresh 30 seconds before the provider says it's expired so the caller doesn't race. + return System.currentTimeMillis() >= (expiresAt - 30_000L); + } + } +} diff --git a/dotCMS/src/main/java/com/dotcms/auth/providers/oauth/viewtool/OAuthTokenToolInfo.java b/dotCMS/src/main/java/com/dotcms/auth/providers/oauth/viewtool/OAuthTokenToolInfo.java new file mode 100644 index 000000000000..1165cd8b6323 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/auth/providers/oauth/viewtool/OAuthTokenToolInfo.java @@ -0,0 +1,30 @@ +package com.dotcms.auth.providers.oauth.viewtool; + +import org.apache.velocity.tools.view.context.ViewContext; +import org.apache.velocity.tools.view.servlet.ServletToolInfo; + +public class OAuthTokenToolInfo extends ServletToolInfo { + + @Override + public String getKey() { + return "oauthToken"; + } + + @Override + public String getScope() { + return ViewContext.REQUEST; + } + + @Override + public String getClassname() { + return OAuthTokenTool.class.getName(); + } + + @Override + public Object getInstance(final Object initData) { + final OAuthTokenTool tool = new OAuthTokenTool(); + tool.init(initData); + setScope(ViewContext.REQUEST); + return tool; + } +} diff --git a/dotCMS/src/main/java/com/dotcms/auth/providers/oauth/viewtool/OAuthTool.java b/dotCMS/src/main/java/com/dotcms/auth/providers/oauth/viewtool/OAuthTool.java new file mode 100644 index 000000000000..a064f90560d5 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/auth/providers/oauth/viewtool/OAuthTool.java @@ -0,0 +1,43 @@ +package com.dotcms.auth.providers.oauth.viewtool; + +import com.dotcms.auth.providers.oauth.OAuthAppConfig; +import com.dotcms.auth.providers.oauth.OAuthConstants; +import javax.servlet.http.HttpServletRequest; +import org.apache.velocity.tools.view.context.ViewContext; +import org.apache.velocity.tools.view.tools.ViewTool; + +/** + * Velocity helper exposing the configured OAuth app to templates. + * Registered as {@code $oauth} in {@code toolbox.xml}. + */ +public class OAuthTool implements ViewTool { + + private HttpServletRequest request; + + @Override + public void init(final Object initData) { + if (initData instanceof ViewContext) { + this.request = ((ViewContext) initData).getRequest(); + } + } + + /** True when the current site (or SYSTEM_HOST) has an enabled dotOAuth app. */ + public boolean isConfigured() { + return OAuthAppConfig.config(request).isPresent(); + } + + /** "OIDC" or "OAuth2" when configured, empty string otherwise. */ + public String getProviderType() { + return OAuthAppConfig.config(request).map(c -> c.providerType).orElse(""); + } + + /** URL that triggers the OAuth flow for the admin console. */ + public String getLoginUrl() { + return "/dotAdmin/"; + } + + /** URL that ends the session (provider logout is handled server-side). */ + public String getLogoutUrl() { + return OAuthConstants.LOGOUT_PATHS[0]; + } +} diff --git a/dotCMS/src/main/java/com/dotcms/auth/providers/oauth/viewtool/OAuthToolInfo.java b/dotCMS/src/main/java/com/dotcms/auth/providers/oauth/viewtool/OAuthToolInfo.java new file mode 100644 index 000000000000..60e65f334e45 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/auth/providers/oauth/viewtool/OAuthToolInfo.java @@ -0,0 +1,30 @@ +package com.dotcms.auth.providers.oauth.viewtool; + +import org.apache.velocity.tools.view.context.ViewContext; +import org.apache.velocity.tools.view.servlet.ServletToolInfo; + +public class OAuthToolInfo extends ServletToolInfo { + + @Override + public String getKey() { + return "oauth"; + } + + @Override + public String getScope() { + return ViewContext.REQUEST; + } + + @Override + public String getClassname() { + return OAuthTool.class.getName(); + } + + @Override + public Object getInstance(final Object initData) { + final OAuthTool tool = new OAuthTool(); + tool.init(initData); + setScope(ViewContext.REQUEST); + return tool; + } +} diff --git a/dotCMS/src/main/java/com/dotcms/auth/providers/saml/v1/DotSamlResource.java b/dotCMS/src/main/java/com/dotcms/auth/providers/saml/v1/DotSamlResource.java index 71d9d233a7c4..fc8382f7f313 100755 --- a/dotCMS/src/main/java/com/dotcms/auth/providers/saml/v1/DotSamlResource.java +++ b/dotCMS/src/main/java/com/dotcms/auth/providers/saml/v1/DotSamlResource.java @@ -1,5 +1,6 @@ package com.dotcms.auth.providers.saml.v1; +import com.dotcms.auth.AuthAccessDeniedUtil; import com.dotcms.filters.interceptor.saml.SamlWebUtils; import com.dotcms.rest.WebResource; import com.dotcms.rest.annotation.NoCache; @@ -22,6 +23,7 @@ import com.dotmarketing.util.WebKeys; import com.google.common.annotations.VisibleForTesting; import com.liferay.portal.model.User; +import io.vavr.control.Try; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Content; @@ -46,6 +48,7 @@ import javax.ws.rs.core.Response; import java.io.IOException; import java.io.Serializable; +import java.net.URI; import java.net.URISyntaxException; import java.util.Arrays; import java.util.List; @@ -267,6 +270,7 @@ public void processLogin(@Parameter(description = "Identity Provider configurati String queryString = (String) session.getAttribute(RequestDispatcher.FORWARD_QUERY_STRING); + boolean loginIntentUnknown = false; String loginPath = httpServletRequest.getParameter("RelayState"); Logger.debug(this, "RelayState, LoginPath: " + loginPath); if (!UtilMethods.isSet(loginPath)) { @@ -285,6 +289,7 @@ public void processLogin(@Parameter(description = "Identity Provider configurati // request to IdP. 'autoLogin' will check the ORIGINAL_REQUEST // session attribute. loginPath = DotSamlConstants.DEFAULT_LOGIN_PATH; + loginIntentUnknown = true; } } else { @@ -299,9 +304,28 @@ public void processLogin(@Parameter(description = "Identity Provider configurati loginPath = loginPath + "?" + queryString; } } - Logger.debug(this, ()-> "Doing login to the user " + (user != null? user.getEmailAddress() : "unknown")); + final User authorizedUser = reloadUser(user); + // IdP-initiated logins carry no RelayState, so the path defaulted to + // /dotAdmin/ above without expressing any real intent. Route those by what + // the user may actually do: users without back-end access get a front-end + // session at "/" when front-end SSO is enabled, instead of a 403 from a + // back-end destination they never asked for. + if (loginIntentUnknown + && !AuthAccessDeniedUtil.hasRequiredRole(authorizedUser, false) + && SAMLHelper.isFrontEndEnabled(identityProviderConfiguration)) { + loginPath = "/"; + } + // Only the back-end is gated here: a front-end SAML login must never be denied, + // since its post-login redirect is a content URL rather than a back-end path. + if (isBackEndLogin(loginPath) + && !AuthAccessDeniedUtil.hasRequiredRole(authorizedUser, false)) { + AuthAccessDeniedUtil.sendNoAccessPage(httpServletResponse, authorizedUser); + return; + } + + Logger.debug(this, ()-> "Doing login to the user " + (authorizedUser != null? authorizedUser.getEmailAddress() : "unknown")); this.samlHelper.doLogin(httpServletRequest, httpServletResponse, - identityProviderConfiguration, user, APILocator.getLoginServiceAPI()); + identityProviderConfiguration, authorizedUser, APILocator.getLoginServiceAPI()); Logger.debug(this, "Final, LoginPath: " + loginPath); RedirectUtil.sendRedirectHTML(httpServletResponse, loginPath); @@ -319,6 +343,26 @@ public void processLogin(@Parameter(description = "Identity Provider configurati throw new DotSamlException(message); } + private static User reloadUser(final User user) { + if (user == null) { + return null; + } + return Try.of(() -> APILocator.getUserAPI() + .loadUserById(user.getUserId(), APILocator.systemUser(), false)) + .getOrElse(user); + } + + /** + * Determines whether the post-login redirect target points at the dotCMS back-end. Only + * back-end logins are subject to the no-access role gate; front-end logins are never denied + * here. Reuses {@link SamlWebUtils#isBackEndLoginPage(String)} so the back-end path set stays + * in one place. + */ + private boolean isBackEndLogin(final String loginPath) { + final String path = Try.of(() -> URI.create(loginPath).getPath()).getOrElse(loginPath); + return UtilMethods.isSet(path) && this.samlWebUtils.isBackEndLoginPage(path); + } + diff --git a/dotCMS/src/main/java/com/dotcms/auth/providers/saml/v1/SAMLHelper.java b/dotCMS/src/main/java/com/dotcms/auth/providers/saml/v1/SAMLHelper.java index 3b6760a83a62..df18c8eba91d 100644 --- a/dotCMS/src/main/java/com/dotcms/auth/providers/saml/v1/SAMLHelper.java +++ b/dotCMS/src/main/java/com/dotcms/auth/providers/saml/v1/SAMLHelper.java @@ -17,6 +17,7 @@ import com.dotmarketing.business.APILocator; import com.dotmarketing.business.DuplicateUserException; import com.dotmarketing.business.NoSuchUserException; +import com.dotmarketing.business.Role; import com.dotmarketing.business.RoleAPI; import com.dotmarketing.business.UserAPI; import com.dotmarketing.business.web.HostWebAPI; @@ -470,6 +471,17 @@ private void handleRoles(final User user, final Attributes attributesBean, UserHelper.getInstance().addRole(user, DotSamlConstants.DOTCMS_SAML_USER_ROLE, true, true); Logger.debug(this, ()->"Default SAML User role has been assigned"); + // System access roles based on enableBackend/enableFrontend config flags. + // addRole dedupes against DOTCMS_SAML_OPTIONAL_USER_ROLE entries. + final boolean enableBackend = isBackEndEnabled(identityProviderConfiguration); + final boolean enableFrontend = isFrontEndEnabled(identityProviderConfiguration); + if (enableBackend) { + UserHelper.getInstance().addRole(user, Role.DOTCMS_BACK_END_USER, false, false); + } + if (enableFrontend) { + UserHelper.getInstance().addRole(user, Role.DOTCMS_FRONT_END_USER, false, false); + } + // the only strategy that does not include the saml user role is the "idp" if (!DotSamlConstants.DOTCMS_SAML_BUILD_ROLES_IDP_VALUE.equalsIgnoreCase(buildRolesStrategy)) { // Add DOTCMS_SAML_OPTIONAL_USER_ROLE @@ -493,6 +505,25 @@ private void handleRoles(final User user, final Attributes attributesBean, } } + /** + * Whether back-end (dotAdmin) SSO is enabled for this IdP configuration. + * Defaults to {@code true} when the flag is absent — historical SAML behavior + * was back-end SSO, so pre-existing configs keep working after upgrade. + */ + public static boolean isBackEndEnabled(final IdentityProviderConfiguration identityProviderConfiguration) { + return !identityProviderConfiguration.containsOptionalProperty("enableBackend") + || BooleanUtils.toBoolean(String.valueOf(identityProviderConfiguration.getOptionalProperty("enableBackend"))); + } + + /** + * Whether front-end (site visitor) SSO is enabled for this IdP configuration. + * Defaults to {@code false} when the flag is absent. + */ + public static boolean isFrontEndEnabled(final IdentityProviderConfiguration identityProviderConfiguration) { + return identityProviderConfiguration.containsOptionalProperty("enableFrontend") + && BooleanUtils.toBoolean(String.valueOf(identityProviderConfiguration.getOptionalProperty("enableFrontend"))); + } + private boolean isValidRole(final String role, final String... rolePatterns) { boolean isValidRole = false; diff --git a/dotCMS/src/main/java/com/dotcms/filters/interceptor/saml/SamlWebUtils.java b/dotCMS/src/main/java/com/dotcms/filters/interceptor/saml/SamlWebUtils.java index dd31928508e8..53f445293b54 100644 --- a/dotCMS/src/main/java/com/dotcms/filters/interceptor/saml/SamlWebUtils.java +++ b/dotCMS/src/main/java/com/dotcms/filters/interceptor/saml/SamlWebUtils.java @@ -141,7 +141,7 @@ protected boolean isBackEndAdmin(final HttpServletRequest request, final String * @return If the URI can be associated to the dotCMS back-end login or logout, returns {@code true}. Otherwise, * returns {@code false}. */ - protected boolean isBackEndLoginPage(final String uri) { + public boolean isBackEndLoginPage(final String uri) { return uri.startsWith("/dotAdmin") || uri.startsWith("/html/portal/login") || uri.startsWith("/c/public/login") || uri.startsWith("/c/portal_public/login") || uri.startsWith("/c/portal/logout"); diff --git a/dotCMS/src/main/java/com/dotcms/http/CircuitBreakerUrl.java b/dotCMS/src/main/java/com/dotcms/http/CircuitBreakerUrl.java index ad8c415a0f3f..035ea962410b 100644 --- a/dotCMS/src/main/java/com/dotcms/http/CircuitBreakerUrl.java +++ b/dotCMS/src/main/java/com/dotcms/http/CircuitBreakerUrl.java @@ -91,6 +91,7 @@ public class CircuitBreakerUrl { private final boolean throwWhenError; private final Function overrideException; private final boolean raiseFailsafe; + private final int maxResponseBytes; public static final Response EMPTY_RESPONSE = new Response<>(StringPool.BLANK, 0, new Header[] {}); @@ -146,7 +147,7 @@ public CircuitBreakerUrl(final String proxyUrl, final Map headers, final boolean verbose, final String rawData) { - this(proxyUrl, timeoutMs, circuitBreaker, request, params, headers, verbose, rawData, false, true, null, false); + this(proxyUrl, timeoutMs, circuitBreaker, request, params, headers, verbose, rawData, false, true, null, false, -1); } @VisibleForTesting @@ -162,6 +163,24 @@ public CircuitBreakerUrl(final String proxyUrl, final boolean throwWhenError, final Function overrideException, final boolean raiseFailsafe) { + this(proxyUrl, timeoutMs, circuitBreaker, request, params, headers, verbose, rawData, + allowRedirects, throwWhenError, overrideException, raiseFailsafe, -1); + } + + @VisibleForTesting + public CircuitBreakerUrl(final String proxyUrl, + final long timeoutMs, + final CircuitBreaker circuitBreaker, + final HttpRequestBase request, + final Map params, + final Map headers, + final boolean verbose, + final String rawData, + final boolean allowRedirects, + final boolean throwWhenError, + final Function overrideException, + final boolean raiseFailsafe, + final int maxResponseBytes) { this.proxyUrl = proxyUrl; this.timeoutMs = timeoutMs; this.circuitBreaker = circuitBreaker; @@ -172,6 +191,7 @@ public CircuitBreakerUrl(final String proxyUrl, this.throwWhenError = throwWhenError; this.overrideException = this.throwWhenError ? overrideException : null; this.raiseFailsafe = raiseFailsafe; + this.maxResponseBytes = maxResponseBytes; for (final String head : headers.keySet()) { request.addHeader(head, headers.get(head)); @@ -219,6 +239,49 @@ public String doString() throws IOException { } } + /** + * Thrown when a response body exceeds the limit configured via + * {@link CircuitBreakerUrlBuilder#setMaxResponseBytes(int)}. Always propagated to the + * caller, regardless of the {@code raiseFailsafe} setting, so an over-limit response can + * never be mistaken for a successful (truncated) one. + */ + public static class ResponseSizeLimitExceededException extends IOException { + private ResponseSizeLimitExceededException(final int maxBytes) { + super("Response exceeded maximum size of " + maxBytes + " bytes"); + } + } + + private static class BoundedOutputStream extends OutputStream { + private final OutputStream delegate; + private final int maxBytes; + private int bytesWritten; + + private BoundedOutputStream(final OutputStream delegate, final int maxBytes) { + this.delegate = delegate; + this.maxBytes = maxBytes; + } + + @Override + public void write(final int b) throws IOException { + checkLimit(1); + delegate.write(b); + bytesWritten++; + } + + @Override + public void write(final byte[] b, final int off, final int len) throws IOException { + checkLimit(len); + delegate.write(b, off, len); + bytesWritten += len; + } + + private void checkLimit(final int bytesToWrite) throws IOException { + if (bytesWritten + bytesToWrite > maxBytes) { + throw new ResponseSizeLimitExceededException(maxBytes); + } + } + } + public void doOut(final OutputStream out) throws IOException { doOut(new EmptyHttpResponse() { @Override @@ -283,7 +346,9 @@ public void doOut(final HttpServletResponse response) throws IOException { this.response = innerResponse.getStatusLine().getStatusCode(); - IOUtils.copy(innerResponse.getEntity().getContent(), out); + IOUtils.copy( + innerResponse.getEntity().getContent(), + maxResponseBytes < 0 ? out : new BoundedOutputStream(out, maxResponseBytes)); } catch (IOException ex) { Logger.error( this, @@ -309,6 +374,14 @@ public void doOut(final HttpServletResponse response) throws IOException { Logger.debug(this.getClass(), ee.getMessage() + " " + this); + if (ee.getCause() instanceof ResponseSizeLimitExceededException) { + // The 2xx status was recorded before the copy aborted; reset it so the partial + // body can never read as a successful response, and fail regardless of + // raiseFailsafe — a truncated payload must not be returned silently. + this.response = -1; + throw (ResponseSizeLimitExceededException) ee.getCause(); + } + if (raiseFailsafe) { throw ee; } diff --git a/dotCMS/src/main/java/com/dotcms/http/CircuitBreakerUrlBuilder.java b/dotCMS/src/main/java/com/dotcms/http/CircuitBreakerUrlBuilder.java index ccedad7a356d..23c9c8e1d3e8 100644 --- a/dotCMS/src/main/java/com/dotcms/http/CircuitBreakerUrlBuilder.java +++ b/dotCMS/src/main/java/com/dotcms/http/CircuitBreakerUrlBuilder.java @@ -38,6 +38,7 @@ public class CircuitBreakerUrlBuilder { boolean throwWhenError = true; Function overrideException; boolean raiseFailsafe = false; + int maxResponseBytes = -1; public CircuitBreakerUrlBuilder setUrl(String proxyUrl) { this.proxyUrl = proxyUrl; @@ -83,6 +84,11 @@ public CircuitBreakerUrlBuilder setRaiseFailsafe(final boolean raiseFailsafe) { this.raiseFailsafe = raiseFailsafe; return this; } + + public CircuitBreakerUrlBuilder setMaxResponseBytes(final int maxResponseBytes) { + this.maxResponseBytes = maxResponseBytes; + return this; + } public CircuitBreakerUrlBuilder setCircuitBreaker(CircuitBreaker circuitBreaker) { this.circuitBreaker = circuitBreaker; @@ -166,7 +172,8 @@ public CircuitBreakerUrl build() { this.allowRedirects, this.throwWhenError, this.overrideException, - this.raiseFailsafe); + this.raiseFailsafe, + this.maxResponseBytes); } /** @@ -178,4 +185,3 @@ public CircuitBreakerUrlBuilder doPing() { return this; } } - diff --git a/dotCMS/src/main/java/com/dotcms/rest/WebResource.java b/dotCMS/src/main/java/com/dotcms/rest/WebResource.java index 31eec612acdb..fbd2d4ff19e7 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/WebResource.java +++ b/dotCMS/src/main/java/com/dotcms/rest/WebResource.java @@ -1,5 +1,7 @@ package com.dotcms.rest; +import com.dotcms.auth.dotAuth.rest.DotAuthSessionCredentialProcessor; +import com.dotcms.auth.dotAuth.rest.DotAuthSessionCredentialProcessorImpl; import com.dotcms.auth.providers.jwt.JsonWebTokenAuthCredentialProcessor; import com.dotcms.auth.providers.jwt.services.JsonWebTokenAuthCredentialProcessorImpl; import com.dotcms.enterprise.LicenseUtil; @@ -69,6 +71,8 @@ public class WebResource { private final UserAPI userAPI; private final LayoutAPI layoutAPI; private final JsonWebTokenAuthCredentialProcessor jsonWebTokenAuthCredentialProcessor; + private final DotAuthSessionCredentialProcessor dotAuthSessionCredentialProcessor = + DotAuthSessionCredentialProcessorImpl.getInstance(); public WebResource() { @@ -607,6 +611,12 @@ public User authenticate(final HttpServletRequest request, final HttpServletResp } } + if(null == user) { + // dotAuth session-ref bearer — short-circuits when the credential carries the + // `dsr_` prefix; returns null for any other bearer so the JWT processor runs next. + user = this.dotAuthSessionCredentialProcessor.processAuthHeaderFromSessionRef(request); + } + if(null == user) { user = this.jsonWebTokenAuthCredentialProcessor.processAuthHeaderFromJWT(request); } diff --git a/dotCMS/src/main/java/com/dotcms/rest/config/DotRestApplication.java b/dotCMS/src/main/java/com/dotcms/rest/config/DotRestApplication.java index f44ca0470f7f..1d04f90394ae 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/config/DotRestApplication.java +++ b/dotCMS/src/main/java/com/dotcms/rest/config/DotRestApplication.java @@ -67,6 +67,7 @@ url = "https://www.dotcms.com/docs/latest/content-type-api")), @Tag(name = "Content Type Field", description = "Content type field definitions and configuration"), @Tag(name = "Data Integrity", description = "Data integrity checking and conflict resolution"), + @Tag(name = "dotAuth", description = "OAuth/OIDC and SAML authentication: per-site configuration (SYSTEM_HOST is the global default) and headless OIDC token exchange"), @Tag(name = "Environment", description = "Publishing environment management and configuration"), @Tag(name = "Experiments", description = "A/B testing and experimentation management"), @Tag(name = "File Assets", description = "File asset management and download operations"), @@ -143,6 +144,7 @@ private void configureApplication() { "com.dotcms.contenttype.model.field", "com.dotcms.rendering.js", "com.dotcms.ai.rest", + "com.dotcms.auth.dotAuth.rest", "com.dotcms.health", "io.swagger.v3.jaxrs2")); diff --git a/dotCMS/src/main/java/com/dotmarketing/business/CacheLocator.java b/dotCMS/src/main/java/com/dotmarketing/business/CacheLocator.java index 6d1abb0cf8b6..534e0b39a9de 100644 --- a/dotCMS/src/main/java/com/dotmarketing/business/CacheLocator.java +++ b/dotCMS/src/main/java/com/dotmarketing/business/CacheLocator.java @@ -2,6 +2,7 @@ import com.dotcms.analytics.attributes.CustomAttributeCache; import com.dotcms.analytics.attributes.CustomAttributeCacheImpl; +import com.dotcms.auth.dotAuth.session.DotAuthSessionCacheImpl; import com.dotcms.auth.providers.jwt.factories.ApiTokenCache; import com.dotcms.business.SystemCache; import com.dotcms.cache.KeyValueCache; @@ -336,6 +337,10 @@ public static ApiTokenCache getApiTokenCache() { return (ApiTokenCache) getInstance(CacheIndex.ApiTokenCache); } + public static DotAuthSessionCacheImpl getDotAuthSessionCache() { + return (DotAuthSessionCacheImpl) getInstance(CacheIndex.DotAuthSessionCache); + } + /** * This will get you an instance of the singleton apps cache. * @return @@ -502,7 +507,8 @@ enum CacheIndex CHAINABLE_404_STORAGE_CACHE("Chainable404StorageCache"), Javascript("Javascript"), JOB_CACHE("JobCache"), - ANALYTICS_CUSTOMATTRIBUTE_CACHE("CustomAttributeCache"); + ANALYTICS_CUSTOMATTRIBUTE_CACHE("CustomAttributeCache"), + DotAuthSessionCache("DotAuthSessionCache"); Cachable create() { switch(this) { @@ -562,6 +568,7 @@ Cachable create() { case Javascript: return new JsCache(); case JOB_CACHE: return new JobCacheImpl(); case ANALYTICS_CUSTOMATTRIBUTE_CACHE: return new CustomAttributeCacheImpl(); + case DotAuthSessionCache: return DotAuthSessionCacheImpl.getInstance(); } throw new AssertionError("Unknown Cache index: " + this); } diff --git a/dotCMS/src/main/java/com/dotmarketing/filters/AutoLoginFilter.java b/dotCMS/src/main/java/com/dotmarketing/filters/AutoLoginFilter.java index f856d847f9b5..929a8e888679 100644 --- a/dotCMS/src/main/java/com/dotmarketing/filters/AutoLoginFilter.java +++ b/dotCMS/src/main/java/com/dotmarketing/filters/AutoLoginFilter.java @@ -9,6 +9,7 @@ */ package com.dotmarketing.filters; +import com.dotcms.auth.providers.oauth.OAuthWebInterceptor; import com.dotcms.cms.login.LogoutWebInterceptor; import com.dotcms.filters.interceptor.AbstractWebInterceptorSupportFilter; import com.dotcms.filters.interceptor.WebInterceptorDelegate; @@ -38,6 +39,7 @@ private void addDefaultInterceptors(final FilterConfig config) { final WebInterceptorDelegate delegate = this.getDelegate(config.getServletContext()); + delegate.add(new OAuthWebInterceptor()); delegate.add(new LogoutWebInterceptor()); delegate.add(new DefaultAutoLoginWebInterceptor()); } // addDefaultInterceptors. diff --git a/dotCMS/src/main/java/com/dotmarketing/startup/runonce/Task260420AddDotAuthPortletToMenu.java b/dotCMS/src/main/java/com/dotmarketing/startup/runonce/Task260420AddDotAuthPortletToMenu.java new file mode 100644 index 000000000000..d8d2bea9c782 --- /dev/null +++ b/dotCMS/src/main/java/com/dotmarketing/startup/runonce/Task260420AddDotAuthPortletToMenu.java @@ -0,0 +1,146 @@ +package com.dotmarketing.startup.runonce; + +import com.dotcms.exception.ExceptionUtil; +import com.dotmarketing.business.CacheLocator; +import com.dotmarketing.common.db.DotConnect; +import com.dotmarketing.db.LocalTransaction; +import com.dotmarketing.exception.DotDataException; +import com.dotmarketing.startup.StartupTask; +import com.dotmarketing.util.Logger; +import com.dotmarketing.util.UUIDUtil; +import com.dotmarketing.util.UtilMethods; + +import java.util.List; +import java.util.Map; + +import static com.dotmarketing.util.PortletID.DOT_AUTH; + +/** + * Adds the {@code dotAuth} portlet to the admin menu. The portlet is inserted + * into every layout that contains the Apps portlet (typically + * "Settings → Configuration") at the position immediately after Apps, since + * {@code dotAuth} is the dedicated editor for the OAuth app and conceptually + * sits alongside it. + *

+ * Layouts that already contain the {@code dotAuth} portlet are skipped so the + * task is safe to re-run and safe in clustered startup where multiple nodes + * may race. + *

+ * If the Apps portlet cannot be located in any layout, the task logs a warning + * and skips the insertion — administrators can add it manually through the + * Roles & Tools UI. + * + * @since Apr 20th, 2026 + */ +public class Task260420AddDotAuthPortletToMenu implements StartupTask { + + private static final String APPS_PORTLET_ID = "apps"; + + private static final String SQL_COUNT_PENDING = + "SELECT COUNT(DISTINCT apps.layout_id) AS count " + + "FROM cms_layouts_portlets apps " + + "WHERE apps.portlet_id = ? " + + "AND NOT EXISTS (" + + " SELECT 1 FROM cms_layouts_portlets existing" + + " WHERE existing.layout_id = apps.layout_id" + + " AND existing.portlet_id = ?)"; + + private static final String SQL_FIND_APPS_LAYOUTS = + "SELECT layout_id, portlet_order FROM cms_layouts_portlets " + + "WHERE portlet_id = ? ORDER BY layout_id, portlet_order"; + + private static final String SQL_COUNT_EXISTING = + "SELECT COUNT(portlet_id) AS count FROM cms_layouts_portlets " + + "WHERE layout_id = ? AND portlet_id = ?"; + + private static final String SQL_SHIFT_ORDER = + "UPDATE cms_layouts_portlets SET portlet_order = portlet_order + 1 " + + "WHERE layout_id = ? AND portlet_order >= ?"; + + private static final String SQL_INSERT = + "INSERT INTO cms_layouts_portlets(id, layout_id, portlet_id, portlet_order) " + + "VALUES (?, ?, ?, ?)"; + + @Override + public boolean forceRun() { + try { + final int pendingCount = new DotConnect() + .setSQL(SQL_COUNT_PENDING) + .addParam(APPS_PORTLET_ID) + .addParam(DOT_AUTH.toString()) + .getInt("count"); + return pendingCount > 0; + } catch (final Exception e) { + Logger.error(this, String.format("An error occurred when checking the 'dotAuth' portlet. " + + "Please verify manually: %s", ExceptionUtil.getErrorMessage(e)), e); + } + return false; + } + + @Override + public void executeUpgrade() throws DotDataException { + Logger.info(this, "Adding 'dotAuth' portlet to the admin menu next to 'apps'"); + + final List> appsLayouts = new DotConnect() + .setSQL(SQL_FIND_APPS_LAYOUTS) + .addParam(APPS_PORTLET_ID) + .loadObjectResults(); + + if (appsLayouts.isEmpty()) { + Logger.warn(this, "Could not find the 'apps' portlet in any layout. " + + "The 'dotAuth' portlet cannot be added automatically. Please add it manually."); + return; + } + + int inserted = 0; + int skipped = 0; + for (final Map row : appsLayouts) { + final String layoutId = row.getOrDefault("layout_id", "").toString(); + if (!UtilMethods.isSet(layoutId)) { + continue; + } + + final int existingDotAuth = new DotConnect() + .setSQL(SQL_COUNT_EXISTING) + .addParam(layoutId) + .addParam(DOT_AUTH.toString()) + .getInt("count"); + if (existingDotAuth > 0) { + skipped++; + continue; + } + + final int appsOrder = Integer.parseInt(row.getOrDefault("portlet_order", "0").toString()); + final int dotAuthOrder = appsOrder + 1; + + try { + LocalTransaction.wrap(() -> { + new DotConnect() + .setSQL(SQL_SHIFT_ORDER) + .addParam(layoutId) + .addParam(dotAuthOrder) + .loadResult(); + + new DotConnect() + .setSQL(SQL_INSERT) + .addParam(UUIDUtil.uuid()) + .addParam(layoutId) + .addParam(DOT_AUTH.toString()) + .addParam(dotAuthOrder) + .loadResult(); + }); + } catch (final com.dotmarketing.exception.DotSecurityException e) { + throw new DotDataException(e.getMessage(), e); + } + + Logger.info(this, "Added 'dotAuth' portlet at position " + dotAuthOrder + " in layout: " + layoutId); + inserted++; + } + + CacheLocator.getLayoutCache().clearCache(); + Logger.info(this, String.format( + "The 'dotAuth' portlet startup task finished — inserted into %d layout(s), skipped %d (already present).", + inserted, skipped)); + } + +} diff --git a/dotCMS/src/main/java/com/dotmarketing/util/PortletID.java b/dotCMS/src/main/java/com/dotmarketing/util/PortletID.java index 9191ba93c09c..9bfafcc308f1 100644 --- a/dotCMS/src/main/java/com/dotmarketing/util/PortletID.java +++ b/dotCMS/src/main/java/com/dotmarketing/util/PortletID.java @@ -55,7 +55,8 @@ public enum PortletID { ANALYTICS_DASHBOARD, USAGE, VELOCITY_PLAYGROUND("velocity_playground"), - VELOCITY_PLAYGROUND_LEGACY("velocity_playground-legacy"); + VELOCITY_PLAYGROUND_LEGACY("velocity_playground-legacy"), + DOT_AUTH("dotAuth"); private final String url; diff --git a/dotCMS/src/main/java/com/dotmarketing/util/TaskLocatorUtil.java b/dotCMS/src/main/java/com/dotmarketing/util/TaskLocatorUtil.java index ea62a7d044b8..bc7d1d3f3435 100644 --- a/dotCMS/src/main/java/com/dotmarketing/util/TaskLocatorUtil.java +++ b/dotCMS/src/main/java/com/dotmarketing/util/TaskLocatorUtil.java @@ -269,6 +269,7 @@ import com.dotmarketing.startup.runonce.Task260506CreateS3VanityAliasTable; import com.dotmarketing.startup.runonce.Task260505AddPluginsPortletToMenu; import com.dotmarketing.startup.runonce.Task260615AlterClusterIdLength; +import com.dotmarketing.startup.runonce.Task260420AddDotAuthPortletToMenu; import com.google.common.collect.ImmutableList; import java.util.ArrayList; @@ -612,6 +613,7 @@ public static List> getStartupRunOnceTaskClasses() { .add(Task260506CreateS3VanityAliasTable.class) .add(Task260505AddPluginsPortletToMenu.class) .add(Task260615AlterClusterIdLength.class) + .add(Task260420AddDotAuthPortletToMenu.class) .build(); return ret.stream().sorted(classNameComparator).collect(Collectors.toList()); diff --git a/dotCMS/src/main/resources/dotmarketing-config.properties b/dotCMS/src/main/resources/dotmarketing-config.properties index eada24900d10..b341215c8ba7 100644 --- a/dotCMS/src/main/resources/dotmarketing-config.properties +++ b/dotCMS/src/main/resources/dotmarketing-config.properties @@ -470,6 +470,12 @@ cache.velocitycache.size=5000 cache.vanityurldirectcache.size=25000 cache.vanityurlsitecache.size=5000 +# dotAuth headless session-ref bearer tokens. Sized for expected concurrent SPA users so +# refs are not LRU-evicted out from under valid sessions (the default 1000 cap would force re-auth). +cache.dotauthsessioncache.size=200000 +# dotAuth one-time-use guard for exchanged id_tokens (replay protection). Sized like the session cache. +cache.dotauthtokenreplaycache.size=200000 + #Available cache regions diff --git a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties index 631f1393b2dc..bcdd475cc14b 100644 --- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties +++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties @@ -695,6 +695,7 @@ com.dotcms.repackage.javax.portlet.title.reports=Reports com.dotcms.repackage.javax.portlet.title.REST_EXAMPLE_PORTLET=Rest Example Portlet com.dotcms.repackage.javax.portlet.title.roles=Roles & Tools com.dotcms.repackage.javax.portlet.title.rules=Rules +com.dotcms.repackage.javax.portlet.title.dotAuth=dotAuth com.dotcms.repackage.javax.portlet.title.site-browser=Browser com.dotcms.repackage.javax.portlet.title.site-search=Site Search com.dotcms.repackage.javax.portlet.title.sites=Sites @@ -7268,3 +7269,400 @@ categories.create.success=Category created successfully. categories.delete.success=Category deleted successfully. categories.update.success=Category updated successfully. categories.more.actions=More Actions + +## dotAuth Portlet +dotauth.header=dotAuth +dotauth.subheader=OAuth / OIDC configuration per site, with a system-wide default. +dotauth.search.placeholder=Search sites +dotauth.table.header.site=Site +dotauth.table.header.status=Status +dotauth.table.header.actions=Actions +dotauth.row.system.label=All Sites (system default) +dotauth.status.site-override=Site override +dotauth.status.inherited=Inherits from System +dotauth.status.not-configured=Not configured +dotauth.status.configured=Configured +dotauth.action.edit=Edit +dotauth.action.clear=Clear +dotauth.action.configure=Configure +dotauth.confirm.clear.system.header=Clear system default? +dotauth.confirm.clear.system.message=This removes the OAuth configuration used by every site that is inheriting from System. Inheriting sites will show "Not configured". +dotauth.confirm.clear.site.header=Clear site override? +dotauth.confirm.clear.site.message=The site will revert to inheriting the System default configuration. +dotauth.banner.inheriting=This site is inheriting the System default. Saving will create a site-specific override. +dotauth.banner.system=These settings apply to every site that does not have its own override. +dotauth.dialog.header.system=All Sites \u2014 system default +dotauth.dialog.header.site=Edit: {0} +dotauth.fieldset.provider=Provider +dotauth.fieldset.endpoints=Endpoints +dotauth.fieldset.exchange=Headless OIDC Exchange +dotauth.fieldset.roles=Roles & Scopes +dotauth.field.enabled=Enabled +dotauth.field.exchangeEnabled=Enable token exchange +dotauth.field.enableBackend=Redirect admin panel +dotauth.field.enableFrontend=Redirect site login +dotauth.field.providerType=Provider type +dotauth.field.providerType.oidc=OpenID Connect (auto-discovery) +dotauth.field.providerType.oauth2=OAuth 2.0 (manual URLs) +dotauth.field.issuerUrl=Issuer URL +dotauth.field.clientId=Client ID +dotauth.field.clientSecret=Client Secret +dotauth.field.scopes=Scopes +dotauth.field.authorizationUrl=Authorization URL +dotauth.field.tokenUrl=Token URL +dotauth.field.userinfoUrl=Userinfo URL +dotauth.field.revocationUrl=Revocation URL +dotauth.field.logoutUrl=Logout URL +dotauth.field.groupsClaim=Groups Claim +dotauth.field.groupsUrl=Groups URL +dotauth.field.extraRoles=Extra Roles +dotauth.field.buildRolesStrategy=Role Sync Strategy +dotauth.field.buildRolesStrategy.all=All - system, static, and IdP roles +dotauth.field.buildRolesStrategy.idp=IdP roles only +dotauth.field.buildRolesStrategy.staticonly=Static roles only +dotauth.field.buildRolesStrategy.staticadd=Add roles without removing existing roles +dotauth.field.buildRolesStrategy.none=Do not sync roles +dotauth.field.hashUserId.tooltip=Recommended. SHA-256 hashes the namespaced IdP subject before storing it as the dotCMS user ID. Avoids reserved characters and length issues. Disable only if you need to interoperate with users created before hashing was supported. +dotauth.validation.summary=Some required fields are missing. Review the highlighted fields before saving. +dotauth.validation.required=This field is required. +dotauth.validation.issue=issue +dotauth.validation.issues=issues +dotauth.validation.headless.no.trusted.idps=At least one trusted IdP is required for headless token exchange. +dotauth.field.authUrl=Authorization URL +dotauth.field.responseType=Response type +dotauth.field.optional.lower=(optional) +dotauth.empty.state.title=No sites yet +dotauth.empty.state.description=Once you add sites, configure OAuth per site here. Start with "All Sites (system default)" above. +# dotAuth phase 3 - SAML +dotauth.column.protocol=SSO protocol +dotauth.protocol.oauth=OAuth 2.0 / OIDC +dotauth.protocol.saml=SAML 2.0 +dotauth.confirm.switch-protocol.header=Switch protocol? +dotauth.confirm.switch-protocol.message=Saving as {0} will delete the existing {1} configuration for this site. Continue? +dotauth.fieldset.saml.idp=IDP Details +dotauth.fieldset.saml.sp=SP Details +dotauth.fieldset.saml.credentials=Credentials +dotauth.field.enable=Enabled +dotauth.field.idpName=IDP Name +dotauth.field.sPIssuerURL=Service Provider Issuer ID +dotauth.field.sPEndpointHostname=Service Provider Endpoint Hostname/Port +dotauth.field.signatureValidationType=Signature Validation +dotauth.field.idPMetadataFile=IDP Metadata XML +dotauth.field.publicCert=Public Certificate +dotauth.field.privateKey=Private Key +dotauth.field.buttonParam=Metadata URL +dotauth.field.sigvalidation.none=None +dotauth.field.sigvalidation.response=Only Response +dotauth.field.sigvalidation.assertion=Only Assertion +dotauth.field.sigvalidation.responseandassertion=Response & Assertion +dotauth.field.buttonParam.title=SP Metadata XML +dotauth.field.buttonParam.download=Download +dotauth.fieldset.saml.custom-attributes=Custom Attributes +dotauth.field.customAttributes.hint=Additional SAML settings passed through to the authentication handler. Useful for attribute mapping (emailAttribute, firstNameAttribute, lastNameAttribute, rolesAttribute), auto-provisioning flags, and anything not covered by the built-in fields above. +dotauth.field.customAttributes.empty=No custom attributes. Click "Add attribute" to add one. +dotauth.field.customAttributes.key=Attribute name +dotauth.field.customAttributes.key.placeholder=e.g. emailAttribute +dotauth.field.customAttributes.value=Value +dotauth.field.customAttributes.value.placeholder=e.g. emailAddress +dotauth.field.customAttributes.add=Add attribute +dotauth.field.customAttributes.remove=Remove attribute +dotauth.field.customAttributes.required=Attribute name is required. +dotauth.field.customAttributes.duplicateKey=Attribute name is already used above. +dotauth.field.customAttributes.reservedKey=Attribute name matches a built-in field — use that field instead. +dotauth.system.hero.title=System identity provider +dotauth.system.hero.subtitle=Set the default interactive SSO and headless exchange configuration inherited by sites. +dotauth.status.headless-enabled=Headless enabled +dotauth.status.enabled=Enabled +dotauth.status.overridden=Overridden +dotauth.status.inherits=Inherits +dotauth.status.disabled=Disabled +dotauth.protocol.none=None +dotauth.stat.sites=Sites +dotauth.stat.sso-enabled=SSO enabled +dotauth.stat.headless-enabled=Headless enabled +dotauth.stat.overrides=Overrides +dotauth.stat.spas=SPAs registered +dotauth.action.open-idp=Open IdP +dotauth.action.configure-provider=Configure provider +dotauth.action.configure-sso=Configure SSO +dotauth.action.configure-headless=Configure headless +dotauth.action.export=Export +dotauth.action.import=Import +dotauth.export.title=Export dotAuth configuration +dotauth.export.description=Downloads one encrypted AppSecrets file containing OAuth/OIDC, SAML, and headless dotAuth configurations. +dotauth.export.password=Export password +dotauth.export.password.help=Use 14 to 32 characters. You will need this same password to import the file. +dotauth.export.success=dotAuth export downloaded +dotauth.export.error=dotAuth export failed +dotauth.import.title=Import dotAuth configuration +dotauth.import.description=Imports a dotAuth AppSecrets export file. The file must contain only dotAuth app configurations. +dotauth.import.file=Export file +dotauth.import.password.help=Enter the password used when the export file was created. +dotauth.import.success=dotAuth configuration imported +dotauth.action.add-site-override=Add site override +dotauth.column.sso-status=SSO status +dotauth.column.headless-status=Headless status +dotauth.empty.identity.title=No identity provider configured +dotauth.empty.identity.description=Configure the system identity provider before sites can inherit SSO or headless exchange defaults. +dotauth.config.system=System default +dotauth.config.title=Identity provider configuration +dotauth.config.nav.status=Status +dotauth.config.nav.protocol=Protocol +dotauth.config.nav.connection=Connection +dotauth.config.nav.provisioning=Provisioning +dotauth.config.nav.session=Session +dotauth.config.nav.enable=Enable +dotauth.config.nav.sessionref=SessionRef lifetime +dotauth.config.nav.trusted-idps=Trusted IdPs +dotauth.config.nav.allowed-origins=Allowed origins +dotauth.config.nav.emergency=Emergency +dotauth.config.sso.title=Single sign-on +dotauth.config.sso.subtitle=Configure browser-based administrator sign-in for this scope. +dotauth.config.headless.title=Headless authentication +dotauth.config.headless.page-title=Headless authentication +dotauth.config.headless.subtitle=Let SPAs exchange verified OIDC tokens for short-lived dotCMS sessionRefs. +dotauth.config.status.title=SSO status +dotauth.config.status.subtitle=The form remains editable while inactive so drafts can be saved. +dotauth.config.protocol.title=Protocol +dotauth.config.protocol.subtitle=Only one interactive SSO protocol is active for a scope. +dotauth.config.oidc.title=OIDC discovery and endpoints +dotauth.config.oidc.subtitle=Paste the issuer discovery URL to populate endpoints automatically, then add client credentials. +dotauth.config.saml.title=SAML identity provider +dotauth.config.saml.subtitle=Store service provider, metadata, certificate, and claim mapping values for SAML sign-in. +dotauth.config.provisioning.title=Provisioning and role mapping +dotauth.config.provisioning.subtitle=Apply the same JIT provisioning strategy to interactive SSO and headless exchange. +dotauth.config.session.title=Session behavior +dotauth.config.headless.enable.subtitle=Accept OIDC tokens from trusted IdPs and issue dotCMS sessionRefs for API calls. +dotauth.config.sessionref.title=SessionRef lifetime +dotauth.config.trusted-idps.title=Trusted IdPs +dotauth.config.trusted-idps.subtitle=Each IdP has its own connection, claim mapping, and provisioning rules. +dotauth.config.allowed-origins.title=Allowed origins +dotauth.config.emergency.title=Emergency controls +dotauth.config.emergency.subtitle=Use these controls when a token or IdP configuration may be compromised. +dotauth.config.unsaved=Unsaved changes +dotauth.config.saved=No pending changes +dotauth.action.back=Back +dotauth.action.discover=Discover +dotauth.action.add-idp=Add IdP +dotauth.action.add-origin=Add origin +dotauth.action.revoke-all=Revoke all sessionRefs +dotauth.action.add-mapping=Add mapping +dotauth.action.save=Save changes +dotauth.action.discard=Discard changes +dotauth.action.leave=Leave page +dotauth.toast.saved=dotAuth changes saved +dotauth.confirm.switch-protocol.message.ui=Existing sessions remain valid until expiry. The previous protocol settings are kept in the form if you switch back before saving. +dotauth.confirm.discard.header=Discard changes? +dotauth.confirm.discard.message=This resets the form to the last saved configuration. +dotauth.confirm.leave.message=You have unsaved changes. Leave the configuration page? +dotauth.confirm.revoke.header=Revoke all sessionRefs? +dotauth.confirm.revoke.message=Every SPA using a dotAuth sessionRef will need to exchange a fresh token. +dotauth.field.sessionTtlMinutes=Session TTL (minutes) +dotauth.field.sessionRefTtlMinutes=sessionRef TTL (minutes) +dotauth.field.defaultRoles=Default roles +dotauth.field.idpGroup=IdP group +dotauth.field.dotcmsRole=dotCMS role +dotauth.field.discoveryUrl=Discovery URL +dotauth.field.jwksUrl=JWKS URL +dotauth.field.audience=Expected audience +dotauth.field.algs=Allowed signing algorithms +dotauth.tab.sso.title=Single sign-on +dotauth.tab.sso.subtitle=Interactive login via your IdP +dotauth.tab.headless.title=Headless authentication +dotauth.tab.headless.subtitle=SPAs exchange OIDC tokens for sessionRefs +dotauth.tab.headless.short=Headless +dotauth.protocol.recommended=Recommended +dotauth.protocol.oauth.description=Use Auth0, Okta, Azure AD, Keycloak, Google Workspace, or any OIDC-compliant IdP. +dotauth.protocol.saml.description=Federated SSO via SAML 2.0 — common for enterprise IdPs like ADFS, Ping, or Shibboleth. +dotauth.status.sso-live=SSO is live. +dotauth.status.sso-live.system=All users sign in through your identity provider. Edits below take effect on save. +dotauth.status.sso-live.site=SSO is live for this site. Edits below take effect on save. +dotauth.status.sso-inactive=SSO is currently inactive. +dotauth.status.sso-inactive.detail=You can configure and save settings without affecting users — turn on the SSO enabled switch above when you're ready. +dotauth.status.override=override +dotauth.status.overrides=overrides +dotauth.banner.overriding=You are overriding the System provider for this site. +dotauth.action.override=Override for this site +dotauth.action.test-connection=Test connection +dotauth.action.replace=Replace +dotauth.action.fetch=Fetch +dotauth.action.cancel=Cancel +dotauth.action.clear-config=Clear configuration +dotauth.action.delete=Delete +dotauth.action.reset-inherit=Reset to inherit +dotauth.config.oidc.connection.title=Connection +dotauth.config.oidc.connection.subtitle=Where dotCMS sends users to authenticate, and how it talks back to your IdP. +dotauth.config.oidc.claims.title=Claim mapping +dotauth.config.oidc.claims.subtitle=Map id_token / userinfo claims to dotCMS user fields. +dotauth.config.saml.sp.title=Service Provider +dotauth.config.saml.sp.subtitle=dotCMS metadata — share these values with your IdP team. +dotauth.config.saml.idp.title=Identity Provider +dotauth.config.saml.idp.subtitle=Upload your IdP's SAML metadata, or fill endpoints manually. +dotauth.config.saml.signing.title=Signing & encryption +dotauth.config.saml.signing.subtitle=Certificates used to sign and (optionally) encrypt SAML messages. +dotauth.config.saml.keyOverrides.title=Key overrides +dotauth.config.saml.keyOverrides.hint=SP signing keys are generated automatically on save if these fields are left blank. Paste your own PEM-encoded key and certificate here to use a custom keypair instead. +dotauth.config.saml.claims.title=Attribute mapping +dotauth.config.saml.claims.subtitle=SAML attribute names → dotCMS user fields. +dotauth.config.advanced=Advanced configuration +dotauth.config.headless.enable.title=Enable token exchange for this scope +dotauth.config.session.subtitle=How long sessions live and where users go when they sign out. +dotauth.config.sessionref.subtitle=Control how long exchanged sessionRefs remain valid. +dotauth.config.allowed-origins.subtitle=Browser origins allowed to call /api/v1/dotauth/oauth/exchange. Wildcards not allowed. +dotauth.field.ssoEnabled=SSO enabled +dotauth.field.secretStored=Secret stored +dotauth.field.optional=(optional) +dotauth.field.pkce=PKCE +dotauth.field.claimEmail=Email +dotauth.field.claimGroups=Groups / roles claim +dotauth.field.claimGroups.saml=Groups attribute +dotauth.field.claimFirstName=First name +dotauth.field.claimLastName=Last name +dotauth.field.entityId=Entity ID +dotauth.field.acsUrl=ACS (Assertion Consumer) URL +dotauth.field.signRequests=Sign requests +dotauth.field.signRequests.detail=Sign AuthnRequest with SP signing key. +dotauth.field.metadataUrl=IdP metadata URL +dotauth.field.metadataXml=IdP metadata XML +dotauth.field.ssoUrl=SSO URL +dotauth.field.sloUrl=SLO URL (optional) +dotauth.field.x509cert=X.509 Certificate +dotauth.field.wantAssertionsSigned=Want assertions signed +dotauth.field.wantResponseSigned=Want response signed +dotauth.field.syncOnLogin=Sync attributes on every login +dotauth.field.syncOnExchange=Sync attributes on every exchange +dotauth.field.autoProvision=Auto-provision new users +dotauth.field.roleBehavior=Role behavior +dotauth.field.roleBehavior.detail=How dotCMS reconciles a user's roles with the IdP-mapped roles. +dotauth.field.groupMappings=Group → role mappings +dotauth.field.groupMappings.detail=Apply roles based on IdP group membership. +dotauth.field.sessionRefTtl=SessionRef lifetime (minutes) +dotauth.field.clampToIdpExp=Clamp to IdP exp +dotauth.field.sessionTtl=Session lifetime (minutes) +dotauth.field.displayName=Display name +dotauth.field.expectedAudience=Expected audience +dotauth.field.allowedAlgs=Allowed signing algorithms +dotauth.field.claimEmail.idp=Email claim +dotauth.field.claimGroups.idp=Groups / roles claim +dotauth.field.claimFirstName.idp=First name claim +dotauth.field.claimLastName.idp=Last name claim +dotauth.discovery.success=Discovery succeeded — endpoints configured automatically. +dotauth.discovery.error=Could not parse discovery document. Check the URL or configure endpoints manually in Advanced below. +dotauth.discovery.configured=Configured by discovery +dotauth.discovery.idp.success=Issuer, JWKS URL, and signing algorithms loaded. +dotauth.discovery.idp.error=Could not parse discovery document. +dotauth.idp.unnamed=(unnamed) +dotauth.idp.noIssuer=no issuer set +dotauth.idp.discovery.label=OIDC discovery +dotauth.idp.connection.label=Connection +dotauth.idp.claims.label=Claim mapping +dotauth.idp.provisioning.label=Provisioning & role mapping +dotauth.empty.trustedIdps=No trusted IdPs configured. Add at least one to accept JWTs from your SPA's identity provider. +dotauth.empty.origins=No origins yet. SPAs running in browsers won't be able to call the exchange endpoint until at least one origin is added. +dotauth.empty.mappings=No mappings yet. New users will receive the default roles above. +dotauth.headless.summary.endpoint.title=Exchange endpoint +dotauth.headless.summary.endpoint.value=POST /api/v1/dotauth/oauth/exchange +dotauth.headless.summary.trusted.title=Trusted tokens only +dotauth.headless.summary.trusted.detail=dotCMS verifies issuer, audience, signature, expiration, and browser origin. +dotauth.headless.summary.session.title=API sessionRef +dotauth.headless.summary.session.detail=The response contains an opaque sessionRef for Content API requests. +dotauth.confirm.clear.title=Clear configuration +dotauth.confirm.clear.message.system=This will permanently delete the system-level dotAuth configuration. SSO and headless exchange will stop working immediately. +dotauth.confirm.clear.message.site=This will permanently delete the site-level dotAuth configuration. SSO and headless exchange will stop working for this site immediately. +dotauth.confirm.clear.instruction=Type clear configuration to confirm +dotauth.confirm.reset.header=Reset to inherit? +dotauth.confirm.reset.message=This will revert the site to inheriting the System default configuration. +dotauth.action.reset=Reset +dotauth.filter.all=All +dotauth.filter.overrides=Overrides +dotauth.filter.sso-on=SSO on +dotauth.filter.headless-on=Headless on +dotauth.filter.disabled=Disabled +dotauth.column.sso=SSO +dotauth.column.headless=Headless +dotauth.saml.metadata-fetch.not-implemented=SAML metadata fetch is not yet available. Enter the IdP fields manually. +## SAML extra properties editor +dotauth.config.saml.extraProps.title=Additional properties +dotauth.config.saml.extraProps.subtitle=Advanced SAML configuration properties. These are passed directly to the SAML handler as key-value pairs. +dotauth.action.add-property=Add property +dotauth.action.download-sp-metadata=Download SP Metadata +dotauth.empty.extraProps=No additional properties configured. +dotauth.field.propertyKey=Property +dotauth.field.propertyValue=Value +## Tooltip text — provisioning +dotauth.tooltip.autoProvision=Create a dotCMS user on first successful sign-in. +dotauth.tooltip.syncOnLogin=Refresh name, email, and group memberships on each sign-in or exchange. +dotauth.tooltip.defaultRoles=Assigned to every provisioned user. Group mappings below add additional roles on top of these. +## Tooltip text — OIDC connection +dotauth.tooltip.discoveryUrl=Paste your IdP's base URL — dotCMS appends .well-known/openid-configuration automatically. +dotauth.tooltip.clientId=From your IdP's application registration. Sometimes called Application ID. +dotauth.tooltip.clientSecret=Encrypted at rest with the system key. Click Replace to enter a new secret — the previous value cannot be retrieved. +dotauth.tooltip.issuerUrl=Must match the iss claim in tokens. Filled automatically by discovery. +dotauth.tooltip.audience=The expected audience (aud) claim in tokens. Usually your application's client ID or API identifier. Leave empty to skip audience validation. +dotauth.tooltip.scopes=Space-separated. openid is required. +dotauth.tooltip.responseType=code is standard for server-side apps. id_token uses the implicit flow. code id_token is hybrid — only use if your IdP requires it. +dotauth.tooltip.pkce=Optional for dotCMS (confidential client) — leave off unless your IdP requires it. +dotauth.tooltip.claimEmail=The JWT claim containing the user's email address. Used as the primary identifier in dotCMS. +dotauth.tooltip.claimGroups=Claim containing group or role memberships. Used for the role mappings in Provisioning. Often a custom claim — check your IdP docs. +## Tooltip text — SAML config +dotauth.tooltip.saml.entityId=Unique identifier for this dotCMS instance as a SAML Service Provider. Share this with your IdP administrator. +dotauth.tooltip.saml.acsUrl=Auto-derived from your dotCMS host. Read-only. +dotauth.tooltip.saml.metadataUrl=dotCMS fetches this periodically to pick up certificate rotations and endpoint changes from your IdP. +dotauth.tooltip.saml.metadataXml=Paste the IdP metadata XML here, or use "Fetch from URL" below. This field is optional — you can save your SP config first, share your SP metadata with the IdP, then come back and add their metadata. +dotauth.config.saml.metadataFetch.title=Fetch from URL +dotauth.config.saml.metadataFetch.hint=Enter the IdP's metadata endpoint URL and click Fetch to download and populate the metadata XML field above. +dotauth.config.saml.metadataManual.title=Manual XML input +dotauth.config.saml.metadataManual.hint=Paste the IdP metadata XML directly. This is optional — you can save your SP config first, share your SP metadata with the IdP, then come back and add theirs. +dotauth.tooltip.saml.x509cert=The IdP's public signing certificate in PEM format. Paste the full certificate including BEGIN/END lines. +dotauth.tooltip.saml.wantAssertionsSigned=Require the IdP to sign the SAML assertion. Recommended for security. +dotauth.tooltip.saml.wantResponseSigned=Require the IdP to sign the entire SAML response envelope. +## Tooltip text — trusted IdP editor +dotauth.tooltip.idp.discoveryUrl=Paste the IdP's base URL or full .well-known URL to auto-fill connection fields. +dotauth.tooltip.idp.displayName=A label for this IdP shown in this list and in audit logs. +dotauth.tooltip.idp.issuerUrl=Must exactly match the iss claim in incoming JWTs. Usually the IdP's base URL. +dotauth.tooltip.idp.jwksUrl=URL where the IdP publishes its JSON Web Key Set. Used to verify JWT signatures. Cached and refreshed automatically on key rotation. +dotauth.tooltip.idp.audience=The audience (aud) claim value from the SPA's JWT — typically the client ID or API identifier registered at the IdP. Not included in discovery; you must set this manually. +dotauth.tooltip.idp.allowedAlgs=Comma-separated. Tokens signed with any other algorithm are rejected. RS256 is the most common. +## Tooltip text — headless section +dotauth.tooltip.sessionRefTtl=How long an issued sessionRef is valid. When it expires, the SPA re-exchanges a fresh IdP JWT. +dotauth.tooltip.clampToIdpExp=Never let a sessionRef outlive the exp claim of the JWT it was minted from. Recommended. +## Tooltip text — SSO session +dotauth.tooltip.sessionTtl=How long the dotCMS session lasts after SSO login. +dotauth.tooltip.logoutUrl=When set, signing out of dotCMS also ends the session at your IdP (OIDC RP-Initiated Logout). Leave empty for dotCMS-only logout. +## Login behavior fields +dotauth.config.loginBehavior.title=Login behavior +dotauth.config.loginBehavior.subtitle=Control which login surfaces redirect to the identity provider and how dotCMS identifies returning users. +dotauth.field.callbackUrl=External dotCMS URL +dotauth.tooltip.callbackUrl=Optional OAuth/OIDC override for the public dotCMS URL used to build the redirect URI sent to the IdP. dotCMS appends /api/v1/oauth/callback when the callback path is omitted. +dotauth.help.callbackUrl=Example: https://cms.example.com becomes https://cms.example.com/api/v1/oauth/callback in your IdP redirect URI settings. +dotauth.tooltip.enableBackend=When enabled, unauthenticated requests to /dotAdmin are redirected to the IdP instead of the native login form. +dotauth.tooltip.enableFrontend=When enabled, unauthenticated requests to site login pages are redirected to the IdP. +dotauth.field.hashUserId=Hash user ID +dotauth.tooltip.hashUserId=Hash the IdP subject into a fixed-length dotCMS user ID. Recommended — avoids issues with special characters in the IdP subject. +## Role behavior labels +dotauth.roleBehavior.syncAll=Sync all +dotauth.roleBehavior.syncAll.description=Remove all existing roles, then re-add system access roles + default roles + IdP group mappings. Keeps roles in sync with the IdP on every login. +dotauth.roleBehavior.idpOnly=IdP groups only +dotauth.roleBehavior.idpOnly.description=Remove all existing roles, then add only IdP group mappings. No system access roles or default roles are added. +dotauth.roleBehavior.staticOnly=Static only +dotauth.roleBehavior.staticOnly.description=Remove all existing roles, then add only system access roles + default roles. IdP groups are ignored. +dotauth.roleBehavior.additive=Additive +dotauth.roleBehavior.additive.description=Keep all existing roles. Add system access roles + default roles + IdP group mappings on top. Roles are never removed. +dotauth.roleBehavior.none=None +dotauth.roleBehavior.none.description=Do not modify roles at all. Roles must be managed manually outside the SSO flow. +dotauth.toc.protocol=Protocol +dotauth.toc.login-behavior=Login Behavior +dotauth.toc.connection=Connection +dotauth.toc.claim-mapping=Claim Mapping +dotauth.toc.provisioning=Provisioning +dotauth.toc.session=Session & Logout +dotauth.toc.service-provider=Service Provider +dotauth.toc.identity-provider=Identity Provider +dotauth.toc.signing=Signing & Encryption +dotauth.toc.attribute-mapping=Attribute Mapping +dotauth.toc.extra-properties=Additional Properties +dotauth.toc.headless.overview=Overview +dotauth.toc.headless.tokens=Tokens +dotauth.toc.headless.idps=Trusted IdPs +dotauth.toc.headless.origins=Allowed Origins +dotauth.toc.headless.emergency=Emergency diff --git a/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml b/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml index cddeb57eab69..215caa6f60ab 100644 --- a/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml +++ b/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml @@ -8,6 +8,9 @@ servers: tags: - description: AI-powered content generation and analysis endpoints name: AI +- description: "OAuth/OIDC and SAML authentication: per-site configuration (SYSTEM_HOST\ + \ is the global default) and headless OIDC token exchange" + name: dotAuth - description: JavaScript execution and server-side scripting name: JavaScript - description: Content bundle management and deployment @@ -9419,6 +9422,380 @@ paths: summary: Update field variable by field variable name tags: - Content Type Field + /v1/dotauth/discover/oidc: + post: + description: "Thin authenticated proxy used by the dotAuth portlet to populate\ + \ issuer, endpoint, JWKS, and supported algorithm fields from a .well-known/openid-configuration\ + \ URL." + operationId: discoverDotAuthOidc + requestBody: + content: + application/json: + schema: + type: object + additionalProperties: + type: object + responses: + "200": + content: + application/json: + schema: + type: object + description: "Parsed OIDC discovery fields: issuer, endpoints, JWKS\ + \ URI, and signing algorithms" + description: Discovery document parsed successfully + "400": + description: Missing or invalid discovery URL + "401": + description: Authentication required + "500": + description: Internal server error + summary: Fetch and parse an OIDC discovery document + tags: + - dotAuth + /v1/dotauth/export: + post: + description: "Exports OAuth/OIDC, SAML, and headless dotAuth AppSecrets into\ + \ one encrypted Apps export file." + operationId: exportDotAuthAppSecrets + requestBody: + content: + application/json: + schema: + type: object + additionalProperties: + type: object + responses: + "200": + content: + application/octet-stream: {} + description: Encrypted export file + "400": + description: Invalid password or no secrets configured + "401": + description: Authentication required + "403": + description: Admin access required + "500": + description: Internal server error + summary: Export all dotAuth AppSecrets + tags: + - dotAuth + /v1/dotauth/fetch/saml-metadata: + post: + description: Authenticated proxy that fetches SAML metadata XML from an IdP's + metadata endpoint URL. Returns the raw XML as a string so the admin can review + it before saving. + operationId: fetchSamlMetadata + requestBody: + content: + application/json: + schema: + type: object + additionalProperties: + type: object + responses: + "200": + content: + application/json: + schema: + type: object + description: Object with a single 'xml' field containing the raw metadata + XML + description: Metadata fetched successfully + "400": + description: Missing or invalid metadata URL + "401": + description: Authentication required + "500": + description: Internal server error + summary: Fetch SAML IdP metadata XML from a URL + tags: + - dotAuth + /v1/dotauth/headless: + delete: + description: Deletes the headless config. SSO config is not affected. + operationId: clearDotAuthHeadlessConfig + responses: + "204": + description: Headless config cleared + "401": + description: Authentication required + "403": + description: Insufficient permissions + "500": + description: Internal server error + summary: Clear the system-level headless token-exchange configuration + tags: + - dotAuth + put: + description: Writes headless config to SYSTEM_HOST. Headless config is system-wide + (not per-site). SSO saves/deletes never affect it. + operationId: saveDotAuthHeadlessConfig + requestBody: + content: + application/json: + schema: + type: object + additionalProperties: + type: object + responses: + "200": + description: Headless config saved + "401": + description: Authentication required + "403": + description: Insufficient permissions + "500": + description: Internal server error + summary: Save the system-level headless token-exchange configuration + tags: + - dotAuth + /v1/dotauth/import: + post: + description: "Imports an encrypted Apps export file containing only OAuth/OIDC,\ + \ SAML, and headless dotAuth AppSecrets." + operationId: importDotAuthAppSecrets + requestBody: + content: + multipart/form-data: + schema: + $ref: "#/components/schemas/FormDataMultiPart" + responses: + "200": + content: + application/json: + schema: + type: object + description: Import result with count of imported secrets + description: Import succeeded + "400": + description: "Invalid password, empty file, or non-dotAuth secrets in file" + "401": + description: Authentication required + "403": + description: Admin access required + "500": + description: Internal server error + summary: Import dotAuth AppSecrets + tags: + - dotAuth + /v1/dotauth/oauth/exchange: + options: + operationId: exchangePreflight + responses: + default: + content: + '*/*': {} + description: default response + tags: + - dotAuth + post: + description: "Validates the caller-supplied id_token against the configured\ + \ OIDC provider's JWKS (signature, iss, aud == our client_id, exp, nonce),\ + \ resolves or JIT-provisions the matching dotCMS user, and returns an opaque\ + \ session-ref bound to that user." + operationId: exchange + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/OAuthExchangeForm" + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/ResponseEntityOAuthExchangeView" + description: Token exchange succeeded + "400": + description: Malformed payload or non-OIDC provider configured + "401": + description: "id_token failed validation (signature, iss, aud, exp, or nonce)" + "403": + description: Resolved dotCMS user exists but is not active + "404": + description: Exchange endpoint not found + "503": + description: OAuth is not configured for this site + summary: Exchange an OIDC id_token for a dotAuth session-ref + tags: + - dotAuth + /v1/dotauth/oauth/session: + delete: + description: "Removes the session referenced by the Authorization: Bearer header\ + \ from the in-memory session cache. Always returns 204 — unknown or missing\ + \ refs are silently ignored so this is safe to call unconditionally on sign-out." + operationId: logout + responses: + "204": + description: Session invalidated (or was unknown) + summary: Invalidate the caller's dotAuth session-ref + tags: + - dotAuth + /v1/dotauth/saml/metadata/{hostId}: + get: + description: "Generates SAML Service Provider metadata XML for the given site.\ + \ For inherited configs the certificate comes from the SYSTEM_HOST row, but\ + \ the entity ID and ACS URL use the target site's hostname so the IdP can\ + \ distinguish per-site requests." + operationId: getSamlSpMetadata + parameters: + - in: path + name: hostId + required: true + schema: + type: string + responses: + "200": + content: + application/xml: {} + description: SP metadata XML + "401": + description: Authentication required + "404": + description: No SAML configuration found for this site + "500": + description: Internal server error + summary: Download SAML SP metadata XML for a site + tags: + - dotAuth + /v1/dotauth/sessionrefs/revoke: + post: + description: Flushes the dotAuth sessionRef cache. Existing browser sessions + are not affected. + operationId: revokeDotAuthSessionRefs + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/ResponseEntityStringView" + description: All session-refs revoked + "401": + description: Authentication required + "403": + description: Admin access required + "500": + description: Internal server error + summary: Revoke all dotAuth sessionRefs + tags: + - dotAuth + /v1/dotauth/sites: + get: + description: "Returns the SYSTEM_HOST (global default) status plus a per-site\ + \ list indicating whether each site has its own OAuth/SAML configuration,\ + \ inherits from the system default, or is unconfigured." + operationId: listDotAuthSites + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/ResponseEntityDotAuthSitesView" + description: Sites retrieved successfully + "401": + description: Authentication required + "403": + description: User does not have permission to access dotAuth + "500": + description: Internal server error + summary: List all sites with their dotAuth status + tags: + - dotAuth + /v1/dotauth/sites/{hostId}: + delete: + description: Deletes both OAuth and SAML secret rows for the given hostId. On + SYSTEM_HOST this removes the global default; non-system hosts fall back to + inheriting from SYSTEM_HOST afterward. + operationId: clearDotAuthConfig + parameters: + - in: path + name: hostId + required: true + schema: + type: string + responses: + "204": + description: Configuration cleared successfully + "401": + description: Authentication required + "403": + description: User does not have permission to edit this site + "404": + description: Site not found + "500": + description: Internal server error + summary: Clear the dotAuth configuration for a site + tags: + - dotAuth + get: + description: "Returns the protocol-specific configuration stored for the given\ + \ hostId. Use the sentinel \"SYSTEM_HOST\" for the global default. When the\ + \ host has no row of its own and the system default is configured, the response\ + \ carries inherited=true and values holds the system defaults. Hidden secrets\ + \ (e.g. clientSecret, privateKey) are masked as \"****\"." + operationId: getDotAuthConfig + parameters: + - in: path + name: hostId + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/ResponseEntityDotAuthConfigView" + description: Configuration retrieved successfully + "401": + description: Authentication required + "403": + description: User does not have permission to read this site + "404": + description: Site not found + "500": + description: Internal server error + summary: Get the dotAuth configuration for a site + tags: + - dotAuth + put: + description: Writes the chosen protocol's secrets for the given hostId and deletes + the other protocol's row for that host (mutual exclusion). Posting "****" + on a hidden field preserves the stored value. + operationId: saveDotAuthConfig + parameters: + - in: path + name: hostId + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/DotAuthConfigForm" + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/ResponseEntityStringView" + description: Configuration saved successfully + "400": + description: Invalid form payload + "401": + description: Authentication required + "403": + description: User does not have permission to edit this site + "404": + description: Site not found + "500": + description: Internal server error + summary: Save (upsert) the dotAuth configuration for a site + tags: + - dotAuth /v1/ema: get: operationId: getDetails @@ -26116,6 +26493,51 @@ components: type: string versionable: type: boolean + DotAuthConfigForm: + type: object + properties: + protocol: + type: string + enum: + - OAUTH + - SAML + values: + type: object + additionalProperties: + type: object + required: + - values + DotAuthConfigView: + type: object + properties: + configured: + type: boolean + headlessValues: + type: object + additionalProperties: + type: object + hostId: + type: string + inherited: + type: boolean + protocol: + type: string + enum: + - OAUTH + - SAML + values: + type: object + additionalProperties: + type: object + DotAuthSitesView: + type: object + properties: + sites: + type: array + items: + $ref: "#/components/schemas/SiteRowView" + system: + $ref: "#/components/schemas/SystemView" DotTempFile: type: object properties: @@ -29429,6 +29851,31 @@ components: required: - assetPath - data + OAuthExchangeForm: + type: object + properties: + expirationDays: + type: integer + format: int32 + idToken: + type: string + nonce: + type: string + required: + - idToken + - nonce + OAuthExchangeView: + type: object + properties: + expirationDays: + type: integer + format: int32 + expiresAt: + type: string + sessionRef: + type: string + user: + $ref: "#/components/schemas/UserSummary" OpenFolderForm: type: object properties: @@ -31377,6 +31824,52 @@ components: type: array items: type: string + ResponseEntityDotAuthConfigView: + type: object + properties: + entity: + $ref: "#/components/schemas/DotAuthConfigView" + errors: + type: array + items: + $ref: "#/components/schemas/ErrorEntity" + i18nMessagesMap: + type: object + additionalProperties: + type: string + messages: + type: array + items: + $ref: "#/components/schemas/MessageEntity" + pagination: + $ref: "#/components/schemas/Pagination" + permissions: + type: array + items: + type: string + ResponseEntityDotAuthSitesView: + type: object + properties: + entity: + $ref: "#/components/schemas/DotAuthSitesView" + errors: + type: array + items: + $ref: "#/components/schemas/ErrorEntity" + i18nMessagesMap: + type: object + additionalProperties: + type: string + messages: + type: array + items: + $ref: "#/components/schemas/MessageEntity" + pagination: + $ref: "#/components/schemas/Pagination" + permissions: + type: array + items: + type: string ResponseEntityDropOldVersionsResultView: type: object properties: @@ -32303,6 +32796,29 @@ components: type: array items: type: string + ResponseEntityOAuthExchangeView: + type: object + properties: + entity: + $ref: "#/components/schemas/OAuthExchangeView" + errors: + type: array + items: + $ref: "#/components/schemas/ErrorEntity" + i18nMessagesMap: + type: object + additionalProperties: + type: string + messages: + type: array + items: + $ref: "#/components/schemas/MessageEntity" + pagination: + $ref: "#/components/schemas/Pagination" + permissions: + type: array + items: + type: string ResponseEntityPageLivePreviewVersionView: type: object properties: @@ -35499,6 +36015,24 @@ components: type: array items: $ref: "#/components/schemas/SimpleSiteVariableForm" + SiteRowView: + type: object + properties: + hostId: + type: string + hostName: + type: string + protocol: + type: string + enum: + - OAUTH + - SAML + status: + type: string + enum: + - SITE_OVERRIDE + - INHERITED + - NOT_CONFIGURED SiteVariableForm: type: object properties: @@ -35631,6 +36165,18 @@ components: - DESTROY workflowAction: $ref: "#/components/schemas/WorkflowAction" + SystemView: + type: object + properties: + configured: + type: boolean + headlessConfigured: + type: boolean + protocol: + type: string + enum: + - OAUTH + - SAML TabDividerField: type: object allOf: @@ -37546,6 +38092,23 @@ components: - path - permissions - type + UserSummary: + type: object + properties: + email: + type: string + firstName: + type: string + fullName: + type: string + lastName: + type: string + roles: + type: array + items: + type: string + userId: + type: string VanityURLView: type: object properties: diff --git a/dotCMS/src/main/webapp/WEB-INF/portlet.xml b/dotCMS/src/main/webapp/WEB-INF/portlet.xml index 77c2b0c8987c..40db8288abe1 100644 --- a/dotCMS/src/main/webapp/WEB-INF/portlet.xml +++ b/dotCMS/src/main/webapp/WEB-INF/portlet.xml @@ -517,4 +517,11 @@ /categories + + dotAuth + dotAuth + com.dotcms.spring.portlet.PortletController + /dotAuth + + diff --git a/dotCMS/src/main/webapp/WEB-INF/toolbox.xml b/dotCMS/src/main/webapp/WEB-INF/toolbox.xml index c8283a814451..29ac767e97eb 100644 --- a/dotCMS/src/main/webapp/WEB-INF/toolbox.xml +++ b/dotCMS/src/main/webapp/WEB-INF/toolbox.xml @@ -337,4 +337,14 @@ request com.dotcms.analytics.viewtool.GoogleAnalyticsTool + + oauth + request + com.dotcms.auth.providers.oauth.viewtool.OAuthTool + + + oauthToken + request + com.dotcms.auth.providers.oauth.viewtool.OAuthTokenTool + diff --git a/dotCMS/src/test/java/com/dotcms/auth/dotAuth/rest/DotAuthOAuthExchangeResourceTest.java b/dotCMS/src/test/java/com/dotcms/auth/dotAuth/rest/DotAuthOAuthExchangeResourceTest.java new file mode 100644 index 000000000000..f698420722e5 --- /dev/null +++ b/dotCMS/src/test/java/com/dotcms/auth/dotAuth/rest/DotAuthOAuthExchangeResourceTest.java @@ -0,0 +1,206 @@ +package com.dotcms.auth.dotAuth.rest; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.dotcms.auth.providers.oauth.OAuthAppConfig; +import com.dotcms.security.apps.Secret; +import com.dotcms.security.apps.Type; +import java.lang.reflect.Constructor; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.ws.rs.core.Response; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +/** + * Unit tests for the guard paths in {@link DotAuthOAuthExchangeResource#exchange}: + * request shape and site-level OAuth configuration. The deeper happy-path (token + * validation + JIT provisioning + session-ref minting) is exercised by integration + * tests against a real IdP; this class pins the short-circuit branches that gate + * the endpoint before any IdP work happens. + */ +class DotAuthOAuthExchangeResourceTest { + + private final DotAuthOAuthExchangeResource resource = new DotAuthOAuthExchangeResource(); + + @Test + void nullForm_returns400() { + final Response resp = resource.exchange( + mock(HttpServletRequest.class), + mock(HttpServletResponse.class), + null); + assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), resp.getStatus()); + } + + @Test + void missingIdToken_returns400() { + final Response resp = resource.exchange( + mock(HttpServletRequest.class), + mock(HttpServletResponse.class), + new OAuthExchangeForm(null, "nonce", 7)); + assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), resp.getStatus()); + } + + @Test + void missingNonce_returns400() { + final Response resp = resource.exchange( + mock(HttpServletRequest.class), + mock(HttpServletResponse.class), + new OAuthExchangeForm("token", null, 7)); + assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), resp.getStatus()); + } + + @Test + void noOAuthConfigForSite_returns503() { + try (MockedStatic appCfg = Mockito.mockStatic(OAuthAppConfig.class)) { + appCfg.when(() -> OAuthAppConfig.exchangeConfig(any(HttpServletRequest.class))) + .thenReturn(Optional.empty()); + + final Response resp = resource.exchange( + mock(HttpServletRequest.class), + mock(HttpServletResponse.class), + new OAuthExchangeForm("token", "nonce", 7)); + assertEquals(Response.Status.SERVICE_UNAVAILABLE.getStatusCode(), resp.getStatus()); + } + } + + @Test + void nonOidcProvider_returns400() { + try (MockedStatic appCfg = Mockito.mockStatic(OAuthAppConfig.class)) { + final OAuthAppConfig config = mock(OAuthAppConfig.class); + when(config.isOidc()).thenReturn(false); + appCfg.when(() -> OAuthAppConfig.exchangeConfig(any(HttpServletRequest.class))) + .thenReturn(Optional.of(config)); + + final HttpServletRequest req = mock(HttpServletRequest.class); + when(req.getRemoteAddr()).thenReturn("127.0.0.1"); + + final Response resp = resource.exchange( + req, + mock(HttpServletResponse.class), + new OAuthExchangeForm("token", "nonce", 7)); + assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), resp.getStatus()); + } + } + + @Test + void originWithEmptyAllowedOrigins_returns403() { + try (MockedStatic appCfg = Mockito.mockStatic(OAuthAppConfig.class)) { + // allowedOriginsJson left null on the mock -> parsed as an empty list. A request + // that carries an Origin header must be denied: the safe default for a + // token-exchange endpoint is deny, NOT allow-any-origin. + final OAuthAppConfig config = mock(OAuthAppConfig.class); + appCfg.when(() -> OAuthAppConfig.exchangeConfig(any(HttpServletRequest.class))) + .thenReturn(Optional.of(config)); + + final HttpServletRequest req = mock(HttpServletRequest.class); + when(req.getHeader("Origin")).thenReturn("https://spa.example.com"); + when(req.getRemoteAddr()).thenReturn("127.0.0.1"); + + final Response resp = resource.exchange( + req, mock(HttpServletResponse.class), + new OAuthExchangeForm("token", "nonce", 7)); + assertEquals(Response.Status.FORBIDDEN.getStatusCode(), resp.getStatus()); + } + } + + @Test + void unlistedOrigin_returns403() throws Exception { + // Build the config BEFORE opening the static mock: the constructor calls + // OAuthAppConfig's own private static helpers, which a MockedStatic would otherwise + // intercept and null out. + final OAuthAppConfig config = headlessConfig(Map.of( + "enabled", "true", + "allowedOrigins", "[\"https://trusted.example.com\"]")); + try (MockedStatic appCfg = Mockito.mockStatic(OAuthAppConfig.class)) { + appCfg.when(() -> OAuthAppConfig.exchangeConfig(any(HttpServletRequest.class))) + .thenReturn(Optional.of(config)); + + final HttpServletRequest req = mock(HttpServletRequest.class); + when(req.getHeader("Origin")).thenReturn("https://evil.example.com"); + when(req.getRemoteAddr()).thenReturn("127.0.0.1"); + + final Response resp = resource.exchange( + req, mock(HttpServletResponse.class), + new OAuthExchangeForm("token", "nonce", 7)); + assertEquals(Response.Status.FORBIDDEN.getStatusCode(), resp.getStatus()); + } + } + + @Test + void untrustedIssuer_returns401() throws Exception { + final OAuthAppConfig config = headlessConfig(Map.of( + "enabled", "true", + "providerType", "OIDC", + "trustedIdps", "[{\"enabled\":true,\"issuer\":\"https://good-idp.example.com\"}]")); + try (MockedStatic appCfg = Mockito.mockStatic(OAuthAppConfig.class)) { + appCfg.when(() -> OAuthAppConfig.exchangeConfig(any(HttpServletRequest.class))) + .thenReturn(Optional.of(config)); + + final HttpServletRequest req = mock(HttpServletRequest.class); + // No Origin header -> CORS check is skipped, reaching the trusted-IdP allowlist. + when(req.getRemoteAddr()).thenReturn("127.0.0.1"); + + final Response resp = resource.exchange( + req, mock(HttpServletResponse.class), + new OAuthExchangeForm(jwtWithIssuer("https://evil-idp.example.com"), "nonce", 7)); + assertEquals(Response.Status.UNAUTHORIZED.getStatusCode(), resp.getStatus()); + } + } + + @Test + void undecodableIssuerWithTrustedIdps_returns400() throws Exception { + final OAuthAppConfig config = headlessConfig(Map.of( + "enabled", "true", + "providerType", "OIDC", + "trustedIdps", "[{\"enabled\":true,\"issuer\":\"https://good-idp.example.com\"}]")); + try (MockedStatic appCfg = Mockito.mockStatic(OAuthAppConfig.class)) { + appCfg.when(() -> OAuthAppConfig.exchangeConfig(any(HttpServletRequest.class))) + .thenReturn(Optional.of(config)); + + final HttpServletRequest req = mock(HttpServletRequest.class); + when(req.getRemoteAddr()).thenReturn("127.0.0.1"); + + final Response resp = resource.exchange( + req, mock(HttpServletResponse.class), + new OAuthExchangeForm("not-a-jwt", "nonce", 7)); + assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), resp.getStatus()); + } + } + + /** + * Build a real headless {@link OAuthAppConfig} via its private secrets constructor. + * The config's fields are {@code public final}, so they cannot be stubbed on a mock — + * we construct a genuine instance from a Secret map carrying the keys under test. + */ + private static OAuthAppConfig headlessConfig(final Map values) throws Exception { + final Map secrets = new HashMap<>(); + values.forEach((k, v) -> secrets.put(k, + Secret.builder().withValue(v).withType(Type.STRING).build())); + final Constructor ctor = + OAuthAppConfig.class.getDeclaredConstructor(Map.class, boolean.class); + ctor.setAccessible(true); + return ctor.newInstance(secrets, true); + } + + /** Build a structurally-valid (unsigned) JWT carrying only an {@code iss} claim. */ + private static String jwtWithIssuer(final String issuer) { + final String header = base64Url("{\"alg\":\"RS256\",\"typ\":\"JWT\"}"); + final String payload = base64Url("{\"iss\":\"" + issuer + "\"}"); + return header + "." + payload + ".sig"; + } + + private static String base64Url(final String json) { + return Base64.getUrlEncoder().withoutPadding() + .encodeToString(json.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/dotCMS/src/test/java/com/dotcms/auth/dotAuth/rest/DotAuthResourceTest.java b/dotCMS/src/test/java/com/dotcms/auth/dotAuth/rest/DotAuthResourceTest.java new file mode 100644 index 000000000000..ee595c501ebb --- /dev/null +++ b/dotCMS/src/test/java/com/dotcms/auth/dotAuth/rest/DotAuthResourceTest.java @@ -0,0 +1,476 @@ +package com.dotcms.auth.dotAuth.rest; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.dotcms.auth.dotAuth.DotAuthConstants; +import com.dotcms.auth.dotAuth.rest.handler.OAuthProtocolHandler; +import com.dotcms.auth.dotAuth.rest.handler.ProtocolHandler; +import com.dotcms.auth.dotAuth.rest.handler.SamlProtocolHandler; +import com.dotcms.rest.InitDataObject; +import com.dotcms.rest.ResponseEntityView; +import com.dotcms.rest.WebResource; +import com.dotcms.saml.DotSamlProxyFactory; +import com.dotcms.security.apps.AppSecrets; +import com.dotcms.security.apps.AppsAPI; +import com.dotcms.security.apps.AppsUtil; +import com.dotmarketing.beans.Host; +import com.dotmarketing.business.APILocator; +import com.dotmarketing.portlets.contentlet.business.HostAPI; +import com.dotmarketing.util.PaginatedArrayList; +import com.liferay.portal.model.User; +import java.security.Key; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.ws.rs.core.Response; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +/** + * Unit tests for {@link DotAuthResource} covering the protocol-dispatch surface + * added in phase-3: SAML detection in {@code listSites}, inheritance from a + * SAML-configured SYSTEM_HOST, per-protocol reads in {@code getConfig}, + * mutual-exclusion on {@code saveConfig}, and dual-key delete on + * {@code clearConfig}. + */ +public class DotAuthResourceTest { + + private static final String OAUTH_KEY = DotAuthConstants.APP_KEY; + private static final String SAML_KEY = DotSamlProxyFactory.SAML_APP_CONFIG_KEY; + + private static final String SYSTEM_HOST_ID = "SYSTEM_HOST"; + private static final String SITE_ID = "site-1"; + private static final String SITE_NAME = "a.example"; + + private WebResource webResource; + private AppsAPI appsAPI; + private HostAPI hostAPI; + private Host systemHost; + private Host site; + private User user; + private HttpServletRequest request; + private HttpServletResponse response; + + private DotAuthResource resource; + + @BeforeEach + public void setUp() throws Exception { + webResource = mock(WebResource.class); + appsAPI = mock(AppsAPI.class); + hostAPI = mock(HostAPI.class); + user = new User(); + request = mock(HttpServletRequest.class); + response = mock(HttpServletResponse.class); + + // InitBuilder#init() delegates to webResource.init(InitBuilder). + final InitDataObject initDataObject = mock(InitDataObject.class); + when(initDataObject.getUser()).thenReturn(user); + when(webResource.init(any(WebResource.InitBuilder.class))).thenReturn(initDataObject); + + systemHost = mock(Host.class); + when(systemHost.getIdentifier()).thenReturn(SYSTEM_HOST_ID); + when(systemHost.isSystemHost()).thenReturn(true); + when(systemHost.isArchived()).thenReturn(false); + + site = mock(Host.class); + when(site.getIdentifier()).thenReturn(SITE_ID); + when(site.getHostname()).thenReturn(SITE_NAME); + when(site.isSystemHost()).thenReturn(false); + when(site.isArchived()).thenReturn(false); + + final Map handlers = new EnumMap<>(DotAuthProtocol.class); + handlers.put(DotAuthProtocol.OAUTH, new OAuthProtocolHandler()); + handlers.put(DotAuthProtocol.SAML, new SamlProtocolHandler()); + + resource = new DotAuthResource(webResource, appsAPI, handlers, + new com.dotcms.auth.dotAuth.rest.handler.HeadlessConfigHelper()); + } + + // --- listSites --------------------------------------------------------- + + @Test + public void listSites_marks_site_with_dotAuth_row_as_OAUTH() throws Exception { + final Map> appsByHost = Map.of( + SYSTEM_HOST_ID.toLowerCase(), Set.of(), + SITE_ID.toLowerCase(), Set.of(OAUTH_KEY.toLowerCase())); + when(appsAPI.appKeysByHost()).thenReturn(appsByHost); + + try (MockedStatic apiLocator = Mockito.mockStatic(APILocator.class)) { + apiLocator.when(APILocator::systemHost).thenReturn(systemHost); + apiLocator.when(APILocator::getHostAPI).thenReturn(hostAPI); + when(hostAPI.search("", false, false, 0, 0, user, false)).thenReturn(paginatedList(site)); + + final Response rsp = resource.listSites(request, response); + final DotAuthSitesView entity = (DotAuthSitesView) ((ResponseEntityView) rsp.getEntity()).getEntity(); + + assertFalse(entity.getSystem().isConfigured()); + assertNull(entity.getSystem().getProtocol()); + assertEquals(1, entity.getSites().size()); + final DotAuthSitesView.SiteRowView row = entity.getSites().get(0); + assertEquals(DotAuthSiteStatus.SITE_OVERRIDE, row.getStatus()); + assertEquals(DotAuthProtocol.OAUTH, row.getProtocol()); + } + } + + @Test + public void listSites_marks_site_with_dotsaml_config_row_as_SAML() throws Exception { + final Map> appsByHost = Map.of( + SYSTEM_HOST_ID.toLowerCase(), Set.of(), + SITE_ID.toLowerCase(), Set.of(SAML_KEY.toLowerCase())); + when(appsAPI.appKeysByHost()).thenReturn(appsByHost); + + try (MockedStatic apiLocator = Mockito.mockStatic(APILocator.class)) { + apiLocator.when(APILocator::systemHost).thenReturn(systemHost); + apiLocator.when(APILocator::getHostAPI).thenReturn(hostAPI); + when(hostAPI.search("", false, false, 0, 0, user, false)).thenReturn(paginatedList(site)); + + final Response rsp = resource.listSites(request, response); + final DotAuthSitesView entity = (DotAuthSitesView) ((ResponseEntityView) rsp.getEntity()).getEntity(); + + final DotAuthSitesView.SiteRowView row = entity.getSites().get(0); + assertEquals(DotAuthSiteStatus.SITE_OVERRIDE, row.getStatus()); + assertEquals(DotAuthProtocol.SAML, row.getProtocol()); + } + } + + @Test + public void listSites_marks_host_inherited_from_SAML_system_default() throws Exception { + final Map> appsByHost = Map.of( + SYSTEM_HOST_ID.toLowerCase(), Set.of(SAML_KEY.toLowerCase()), + SITE_ID.toLowerCase(), Set.of()); + when(appsAPI.appKeysByHost()).thenReturn(appsByHost); + + try (MockedStatic apiLocator = Mockito.mockStatic(APILocator.class)) { + apiLocator.when(APILocator::systemHost).thenReturn(systemHost); + apiLocator.when(APILocator::getHostAPI).thenReturn(hostAPI); + when(hostAPI.search("", false, false, 0, 0, user, false)).thenReturn(paginatedList(site)); + + final Response rsp = resource.listSites(request, response); + final DotAuthSitesView entity = (DotAuthSitesView) ((ResponseEntityView) rsp.getEntity()).getEntity(); + + assertTrue(entity.getSystem().isConfigured()); + assertEquals(DotAuthProtocol.SAML, entity.getSystem().getProtocol()); + final DotAuthSitesView.SiteRowView row = entity.getSites().get(0); + assertEquals(DotAuthSiteStatus.INHERITED, row.getStatus()); + assertEquals(DotAuthProtocol.SAML, row.getProtocol()); + } + } + + @Test + public void listSites_marks_host_not_configured_when_no_keys_anywhere() throws Exception { + when(appsAPI.appKeysByHost()).thenReturn(Map.of()); + + try (MockedStatic apiLocator = Mockito.mockStatic(APILocator.class)) { + apiLocator.when(APILocator::systemHost).thenReturn(systemHost); + apiLocator.when(APILocator::getHostAPI).thenReturn(hostAPI); + when(hostAPI.search("", false, false, 0, 0, user, false)).thenReturn(paginatedList(site)); + + final Response rsp = resource.listSites(request, response); + final DotAuthSitesView entity = (DotAuthSitesView) ((ResponseEntityView) rsp.getEntity()).getEntity(); + + final DotAuthSitesView.SiteRowView row = entity.getSites().get(0); + assertEquals(DotAuthSiteStatus.NOT_CONFIGURED, row.getStatus()); + assertNull(row.getProtocol()); + } + } + + // --- getConfig --------------------------------------------------------- + + @Test + public void getConfig_returns_SAML_values_for_SAML_configured_host() throws Exception { + final AppSecrets samlSecrets = AppSecrets.builder() + .withKey(SAML_KEY) + .withSecret("idpName", "Okta") + .withHiddenSecret("privateKey", "stored-PEM") + .build(); + + when(appsAPI.getSecrets(eq(OAUTH_KEY), anyBoolean(), eq(site), eq(user))) + .thenReturn(Optional.empty()); + when(appsAPI.getSecrets(eq(SAML_KEY), anyBoolean(), eq(site), eq(user))) + .thenReturn(Optional.of(samlSecrets)); + + try (MockedStatic apiLocator = Mockito.mockStatic(APILocator.class)) { + apiLocator.when(APILocator::systemHost).thenReturn(systemHost); + apiLocator.when(APILocator::getHostAPI).thenReturn(hostAPI); + when(hostAPI.find(SITE_ID, user, false)).thenReturn(site); + + final Response rsp = resource.getConfig(request, response, SITE_ID); + final DotAuthConfigView entity = + (DotAuthConfigView) ((ResponseEntityView) rsp.getEntity()).getEntity(); + + assertEquals(DotAuthProtocol.SAML, entity.getProtocol()); + assertTrue(entity.isConfigured()); + assertFalse(entity.isInherited()); + assertEquals("Okta", entity.getValues().get("idpName")); + assertEquals(DotAuthConstants.HIDDEN_SECRET_MASK, entity.getValues().get("privateKey")); + } + } + + @Test + public void getConfig_prefers_newer_SAML_when_stale_OAUTH_cleanup_failed() throws Exception { + final AppSecrets oauthSecrets = AppSecrets.builder() + .withKey(OAUTH_KEY) + .withSecret("clientId", "old-oauth") + .withSecret(DotAuthConstants.LAST_SAVED_PROTOCOL_AT_KEY, "100") + .build(); + final AppSecrets samlSecrets = AppSecrets.builder() + .withKey(SAML_KEY) + .withSecret("idpName", "new-saml") + .withSecret(DotAuthConstants.LAST_SAVED_PROTOCOL_AT_KEY, "200") + .build(); + + when(appsAPI.getSecrets(eq(OAUTH_KEY), eq(false), eq(site), eq(user))) + .thenReturn(Optional.of(oauthSecrets)); + when(appsAPI.getSecrets(eq(SAML_KEY), eq(false), eq(site), eq(user))) + .thenReturn(Optional.of(samlSecrets)); + + try (MockedStatic apiLocator = Mockito.mockStatic(APILocator.class)) { + apiLocator.when(APILocator::systemHost).thenReturn(systemHost); + apiLocator.when(APILocator::getHostAPI).thenReturn(hostAPI); + when(hostAPI.find(SITE_ID, user, false)).thenReturn(site); + + final Response rsp = resource.getConfig(request, response, SITE_ID); + final DotAuthConfigView entity = + (DotAuthConfigView) ((ResponseEntityView) rsp.getEntity()).getEntity(); + + assertEquals(DotAuthProtocol.SAML, entity.getProtocol()); + assertEquals("new-saml", entity.getValues().get("idpName")); + assertFalse(entity.getValues().containsKey(DotAuthConstants.LAST_SAVED_PROTOCOL_AT_KEY)); + } + } + + @Test + public void getConfig_returns_OAUTH_inherited_when_only_system_has_dotAuth() throws Exception { + final AppSecrets oauthSecrets = AppSecrets.builder() + .withKey(OAUTH_KEY) + .withSecret("clientId", "abc") + .build(); + + when(appsAPI.getSecrets(eq(OAUTH_KEY), eq(false), eq(site), eq(user))) + .thenReturn(Optional.empty()); + when(appsAPI.getSecrets(eq(SAML_KEY), eq(false), eq(site), eq(user))) + .thenReturn(Optional.empty()); + when(appsAPI.getSecrets(eq(OAUTH_KEY), eq(true), eq(site), eq(user))) + .thenReturn(Optional.of(oauthSecrets)); + + try (MockedStatic apiLocator = Mockito.mockStatic(APILocator.class)) { + apiLocator.when(APILocator::systemHost).thenReturn(systemHost); + apiLocator.when(APILocator::getHostAPI).thenReturn(hostAPI); + when(hostAPI.find(SITE_ID, user, false)).thenReturn(site); + + final Response rsp = resource.getConfig(request, response, SITE_ID); + final DotAuthConfigView entity = + (DotAuthConfigView) ((ResponseEntityView) rsp.getEntity()).getEntity(); + + assertEquals(DotAuthProtocol.OAUTH, entity.getProtocol()); + assertFalse(entity.isConfigured()); + assertTrue(entity.isInherited()); + assertEquals("abc", entity.getValues().get("clientId")); + } + } + + @Test + public void getConfig_defaults_to_OAUTH_when_nothing_configured_anywhere() throws Exception { + when(appsAPI.getSecrets(any(), anyBoolean(), any(Host.class), eq(user))) + .thenReturn(Optional.empty()); + + try (MockedStatic apiLocator = Mockito.mockStatic(APILocator.class)) { + apiLocator.when(APILocator::systemHost).thenReturn(systemHost); + apiLocator.when(APILocator::getHostAPI).thenReturn(hostAPI); + when(hostAPI.find(SITE_ID, user, false)).thenReturn(site); + + final Response rsp = resource.getConfig(request, response, SITE_ID); + final DotAuthConfigView entity = + (DotAuthConfigView) ((ResponseEntityView) rsp.getEntity()).getEntity(); + + assertEquals(DotAuthProtocol.OAUTH, entity.getProtocol()); + assertFalse(entity.isConfigured()); + assertFalse(entity.isInherited()); + assertNotNull(entity.getValues()); + assertTrue(entity.getValues().isEmpty()); + } + } + + // --- saveConfig (mutual exclusion) ------------------------------------- + + @Test + public void saveConfig_with_OAUTH_deletes_existing_dotsaml_config_for_host() throws Exception { + final DotAuthConfigForm form = new DotAuthConfigForm( + DotAuthProtocol.OAUTH, Map.of("clientId", "abc")); + + when(appsAPI.getSecrets(eq(OAUTH_KEY), anyBoolean(), eq(site), eq(user))) + .thenReturn(Optional.empty()); + + try (MockedStatic apiLocator = Mockito.mockStatic(APILocator.class)) { + apiLocator.when(APILocator::systemHost).thenReturn(systemHost); + apiLocator.when(APILocator::getHostAPI).thenReturn(hostAPI); + when(hostAPI.find(SITE_ID, user, false)).thenReturn(site); + + resource.saveConfig(request, response, SITE_ID, form); + + verify(appsAPI).deleteSecrets(SAML_KEY, site, user); + verify(appsAPI, never()).deleteSecrets(eq(OAUTH_KEY), any(Host.class), any(User.class)); + verify(appsAPI).saveSecrets(any(AppSecrets.class), eq(site), eq(user)); + } + } + + @Test + public void saveConfig_with_SAML_deletes_existing_dotAuth_for_host() throws Exception { + final DotAuthConfigForm form = new DotAuthConfigForm( + DotAuthProtocol.SAML, Map.of("idpName", "Okta")); + + when(appsAPI.getSecrets(eq(SAML_KEY), anyBoolean(), eq(site), eq(user))) + .thenReturn(Optional.empty()); + + try (MockedStatic apiLocator = Mockito.mockStatic(APILocator.class)) { + apiLocator.when(APILocator::systemHost).thenReturn(systemHost); + apiLocator.when(APILocator::getHostAPI).thenReturn(hostAPI); + when(hostAPI.find(SITE_ID, user, false)).thenReturn(site); + + resource.saveConfig(request, response, SITE_ID, form); + + verify(appsAPI).deleteSecrets(OAUTH_KEY, site, user); + verify(appsAPI, never()).deleteSecrets(eq(SAML_KEY), any(Host.class), any(User.class)); + verify(appsAPI).saveSecrets(any(AppSecrets.class), eq(site), eq(user)); + } + } + + @Test + public void saveConfig_preserves_masked_secret_from_inherited_system_config() throws Exception { + final DotAuthConfigForm form = new DotAuthConfigForm( + DotAuthProtocol.OAUTH, + Map.of("clientId", "site-client", "clientSecret", DotAuthConstants.HIDDEN_SECRET_MASK)); + final AppSecrets systemSecrets = AppSecrets.builder() + .withKey(OAUTH_KEY) + .withHiddenSecret("clientSecret", "system-secret") + .build(); + + when(appsAPI.getSecrets(eq(OAUTH_KEY), eq(true), eq(site), eq(user))) + .thenReturn(Optional.of(systemSecrets)); + + try (MockedStatic apiLocator = Mockito.mockStatic(APILocator.class)) { + apiLocator.when(APILocator::systemHost).thenReturn(systemHost); + apiLocator.when(APILocator::getHostAPI).thenReturn(hostAPI); + when(hostAPI.find(SITE_ID, user, false)).thenReturn(site); + + resource.saveConfig(request, response, SITE_ID, form); + + final ArgumentCaptor saved = ArgumentCaptor.forClass(AppSecrets.class); + verify(appsAPI).saveSecrets(saved.capture(), eq(site), eq(user)); + assertEquals("system-secret", + saved.getValue().getSecrets().get("clientSecret").getString()); + } + } + + @Test + public void saveConfig_with_missing_protocol_defaults_to_OAUTH() throws Exception { + final DotAuthConfigForm form = new DotAuthConfigForm(null, Map.of("clientId", "abc")); + + when(appsAPI.getSecrets(eq(OAUTH_KEY), anyBoolean(), eq(site), eq(user))) + .thenReturn(Optional.empty()); + + try (MockedStatic apiLocator = Mockito.mockStatic(APILocator.class)) { + apiLocator.when(APILocator::systemHost).thenReturn(systemHost); + apiLocator.when(APILocator::getHostAPI).thenReturn(hostAPI); + when(hostAPI.find(SITE_ID, user, false)).thenReturn(site); + + resource.saveConfig(request, response, SITE_ID, form); + + // Missing protocol → OAUTH chosen → SAML row is the one deleted. + verify(appsAPI).deleteSecrets(SAML_KEY, site, user); + verify(appsAPI, never()).deleteSecrets(eq(OAUTH_KEY), any(Host.class), any(User.class)); + } + } + + // --- clearConfig -------------------------------------------------------- + + @Test + public void clearConfig_deletes_both_dotAuth_and_dotsaml_config() throws Exception { + try (MockedStatic apiLocator = Mockito.mockStatic(APILocator.class)) { + apiLocator.when(APILocator::systemHost).thenReturn(systemHost); + apiLocator.when(APILocator::getHostAPI).thenReturn(hostAPI); + when(hostAPI.find(SITE_ID, user, false)).thenReturn(site); + + final Response rsp = resource.clearConfig(request, response, SITE_ID); + + assertEquals(Response.Status.NO_CONTENT.getStatusCode(), rsp.getStatus()); + verify(appsAPI).deleteSecrets(OAUTH_KEY, site, user); + verify(appsAPI).deleteSecrets(SAML_KEY, site, user); + } + } + + // --- auth gating ------------------------------------------------------- + + @Test + public void listSites_maps_security_failure_to_error_response_when_no_user() { + // WebResource.InitBuilder#init() raises a NotAuthorizedException when + // rejectWhenNoUser(true) is set and there's no authenticated user. + // Using the JAX-RS type because DotSecurityException is checked and + // cannot be passed to Mockito's thenThrow() on init()'s signature. + when(webResource.init(any(WebResource.InitBuilder.class))) + .thenThrow(new javax.ws.rs.NotAuthorizedException("No user in request")); + + final Response rsp = resource.listSites(request, response); + + // ResponseUtil.mapExceptionResponse turns the security exception into + // a 401/403 response — either way, NOT a 200 OK. + assertTrue(rsp.getStatus() >= 400, + "Expected a non-2xx response when the user is rejected, got " + rsp.getStatus()); + } + + // --- import ------------------------------------------------------------ + + /** + * Regression: dotAuth OAuth (and headless) configs store secrets directly and have no + * Apps descriptor YAML — only SAML does. Import must NOT require a descriptor, mirroring + * the save path. Previously this threw a 400 ("No App Descriptor found for `dotAuth`"). + */ + @Test + public void importDotAuthFile_imports_oauth_without_requiring_app_descriptor() throws Exception { + final AppSecrets oauthSecrets = AppSecrets.builder() + .withKey(OAUTH_KEY) + .withSecret("clientId", "abc") + .build(); + + try (MockedStatic apiLocator = Mockito.mockStatic(APILocator.class); + MockedStatic appsUtil = Mockito.mockStatic(AppsUtil.class)) { + apiLocator.when(APILocator::systemHost).thenReturn(systemHost); + appsUtil.when(() -> AppsUtil.importSecrets(any(), any())) + .thenReturn(Map.of(Host.SYSTEM_HOST, List.of(oauthSecrets))); + + final int imported = resource.importDotAuthFile( + java.nio.file.Paths.get("ignored.export"), mock(Key.class), user); + + assertEquals(1, imported); + verify(appsAPI).saveSecrets(any(AppSecrets.class), eq(systemHost), eq(user)); + // The descriptor lookup is the regression: import must not gate on it. + verify(appsAPI, never()).getAppDescriptor(any(), any()); + } + } + + private static PaginatedArrayList paginatedList(final Host... hosts) { + final PaginatedArrayList list = new PaginatedArrayList<>(); + for (final Host h : hosts) { + list.add(h); + } + return list; + } +} diff --git a/dotCMS/src/test/java/com/dotcms/auth/dotAuth/rest/DotAuthSessionCredentialProcessorImplTest.java b/dotCMS/src/test/java/com/dotcms/auth/dotAuth/rest/DotAuthSessionCredentialProcessorImplTest.java new file mode 100644 index 000000000000..c810056ac8a1 --- /dev/null +++ b/dotCMS/src/test/java/com/dotcms/auth/dotAuth/rest/DotAuthSessionCredentialProcessorImplTest.java @@ -0,0 +1,131 @@ +package com.dotcms.auth.dotAuth.rest; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.dotcms.auth.dotAuth.session.DotAuthSession; +import com.dotcms.auth.dotAuth.session.DotAuthSessionCache; +import com.dotcms.rest.exception.SecurityException; +import com.dotmarketing.business.APILocator; +import com.dotmarketing.business.UserAPI; +import com.liferay.portal.model.User; +import java.util.Optional; +import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.core.Response; +import org.glassfish.jersey.server.ContainerRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +/** + * Unit tests for {@link DotAuthSessionCredentialProcessorImpl}: prefix-based + * short-circuit to dotAuth sessions, 401 on unknown / inactive sessions, and + * null-return for any bearer that isn't one of ours (so the JWT processor gets + * its turn downstream). + */ +class DotAuthSessionCredentialProcessorImplTest { + + private DotAuthSessionCache cache; + private DotAuthSessionCredentialProcessorImpl processor; + + @BeforeEach + void setUp() { + cache = mock(DotAuthSessionCache.class); + processor = new DotAuthSessionCredentialProcessorImpl(cache); + } + + @Test + void noAuthorizationHeader_returnsNull_soChainCanContinue() { + final HttpServletRequest request = mock(HttpServletRequest.class); + when(request.getHeader(ContainerRequest.AUTHORIZATION)).thenReturn(null); + + assertNull(processor.processAuthHeaderFromSessionRef(request)); + } + + @Test + void jwtBearer_returnsNull_soJwtProcessorRunsNext() { + final HttpServletRequest request = mock(HttpServletRequest.class); + when(request.getHeader(ContainerRequest.AUTHORIZATION)) + .thenReturn("Bearer eyJhbGciOiJIUzI1NiJ9.fake.signature"); + + assertNull(processor.processAuthHeaderFromSessionRef(request)); + } + + @Test + void sessionRefUnknown_throwsUnauthorized() { + final HttpServletRequest request = mock(HttpServletRequest.class); + when(request.getHeader(ContainerRequest.AUTHORIZATION)) + .thenReturn("Bearer " + DotAuthSessionCache.SESSION_REF_PREFIX + "deadbeef"); + when(cache.get(any())).thenReturn(Optional.empty()); + + final SecurityException ex = assertThrows(SecurityException.class, + () -> processor.processAuthHeaderFromSessionRef(request)); + assertEquals(Response.Status.UNAUTHORIZED.getStatusCode(), + ex.getResponse().getStatus(), + "unknown session-ref must surface as 401, not be ignored"); + } + + @Test + void sessionRefInactiveUser_invalidatesAndThrows() throws Exception { + final String ref = DotAuthSessionCache.SESSION_REF_PREFIX + "abc"; + final HttpServletRequest request = mock(HttpServletRequest.class); + when(request.getHeader(ContainerRequest.AUTHORIZATION)).thenReturn("Bearer " + ref); + when(cache.get(ref)) + .thenReturn(Optional.of(new DotAuthSession("user-1", 0L, Long.MAX_VALUE))); + + final User user = mock(User.class); + when(user.isActive()).thenReturn(false); + + final UserAPI userAPI = mock(UserAPI.class); + try (MockedStatic api = Mockito.mockStatic(APILocator.class)) { + api.when(APILocator::getUserAPI).thenReturn(userAPI); + when(userAPI.loadUserById("user-1")).thenReturn(user); + + final SecurityException ex = assertThrows(SecurityException.class, + () -> processor.processAuthHeaderFromSessionRef(request)); + assertEquals(Response.Status.UNAUTHORIZED.getStatusCode(), + ex.getResponse().getStatus()); + } + verify(cache).invalidate(ref); + } + + @Test + void sessionRefActiveUser_returnsUserAndStampsRequest() throws Exception { + final String ref = DotAuthSessionCache.SESSION_REF_PREFIX + "xyz"; + final HttpServletRequest request = mock(HttpServletRequest.class); + when(request.getHeader(ContainerRequest.AUTHORIZATION)).thenReturn("Bearer " + ref); + when(cache.get(ref)) + .thenReturn(Optional.of(new DotAuthSession("user-7", 0L, Long.MAX_VALUE))); + + final User user = mock(User.class); + when(user.isActive()).thenReturn(true); + when(user.getUserId()).thenReturn("user-7"); + + final UserAPI userAPI = mock(UserAPI.class); + try (MockedStatic api = Mockito.mockStatic(APILocator.class)) { + api.when(APILocator::getUserAPI).thenReturn(userAPI); + when(userAPI.loadUserById("user-7")).thenReturn(user); + + final User resolved = processor.processAuthHeaderFromSessionRef(request); + assertTrue(resolved == user, "must return the exact user resolved from the session"); + } + verify(request).setAttribute(com.liferay.portal.util.WebKeys.USER_ID, "user-7"); + verify(request).setAttribute(com.liferay.portal.util.WebKeys.USER, user); + } + + @Test + void bearerPrefixWithoutSessionRefPrefix_returnsNull() { + final HttpServletRequest request = mock(HttpServletRequest.class); + when(request.getHeader(ContainerRequest.AUTHORIZATION)) + .thenReturn("Bearer some-opaque-not-a-jwt-and-not-ours"); + + assertNull(processor.processAuthHeaderFromSessionRef(request)); + } +} diff --git a/dotCMS/src/test/java/com/dotcms/auth/dotAuth/rest/handler/OAuthProtocolHandlerTest.java b/dotCMS/src/test/java/com/dotcms/auth/dotAuth/rest/handler/OAuthProtocolHandlerTest.java new file mode 100644 index 000000000000..eddcfea2e80b --- /dev/null +++ b/dotCMS/src/test/java/com/dotcms/auth/dotAuth/rest/handler/OAuthProtocolHandlerTest.java @@ -0,0 +1,96 @@ +package com.dotcms.auth.dotAuth.rest.handler; + +import com.dotcms.auth.dotAuth.DotAuthConstants; +import com.dotcms.auth.dotAuth.rest.DotAuthProtocol; +import com.dotcms.auth.providers.oauth.OAuthAppConfig; +import com.dotcms.security.apps.AppSecrets; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +public class OAuthProtocolHandlerTest { + + private final OAuthProtocolHandler handler = new OAuthProtocolHandler(); + + @Test + public void protocol_is_OAUTH() { + assertEquals(DotAuthProtocol.OAUTH, handler.protocol()); + } + + @Test + public void appKey_is_dotAuth() { + assertEquals(DotAuthConstants.APP_KEY, handler.appKey()); + } + + @Test + public void secretKeys_include_sso_keys() { + assertTrue(handler.secretKeys().contains(OAuthAppConfig.KEY_CLIENT_SECRET)); + assertTrue(handler.secretKeys().contains(OAuthAppConfig.KEY_ISSUER_URL)); + assertTrue(handler.secretKeys().contains(OAuthAppConfig.KEY_CALLBACK_URL)); + assertTrue(handler.secretKeys().contains(OAuthAppConfig.KEY_BUILD_ROLES_STRATEGY)); + } + + @Test + public void secretKeys_do_not_include_exchange_or_headless_keys() { + assertFalse(handler.secretKeys().contains("exchangeClientSecret")); + assertFalse(handler.secretKeys().contains("headlessSessionRefTtlMinutes")); + assertFalse(handler.secretKeys().contains("exchangeEnabled")); + } + + @Test + public void clientSecret_is_hidden() { + assertTrue(handler.hiddenKeys().contains(OAuthAppConfig.KEY_CLIENT_SECRET)); + assertFalse(handler.hiddenKeys().contains("exchangeClientSecret")); + } + + @Test + public void enabled_is_boolean() { + assertTrue(handler.booleanKeys().contains(OAuthAppConfig.KEY_ENABLED)); + assertFalse(handler.booleanKeys().contains("exchangeEnabled")); + } + + @Test + public void hashUserId_is_registered_as_boolean_secret() { + assertTrue(handler.secretKeys().contains(OAuthAppConfig.KEY_HASH_USERID)); + assertTrue(handler.booleanKeys().contains(OAuthAppConfig.KEY_HASH_USERID)); + } + + @Test + public void buildSecrets_persists_hashUserId_boolean() { + final AppSecrets result = handler.buildSecrets( + Map.of(OAuthAppConfig.KEY_HASH_USERID, "false"), + Optional.empty()); + + assertFalse(result.getSecrets().get(OAuthAppConfig.KEY_HASH_USERID).getBoolean()); + } + + @Test + public void maskedValues_replaces_clientSecret_with_mask() { + final AppSecrets secrets = AppSecrets.builder() + .withKey(DotAuthConstants.APP_KEY) + .withHiddenSecret(OAuthAppConfig.KEY_CLIENT_SECRET, "real-secret") + .withSecret(OAuthAppConfig.KEY_CLIENT_ID, "my-id") + .build(); + + final Map out = handler.maskedValues(secrets); + + assertEquals("****", out.get(OAuthAppConfig.KEY_CLIENT_SECRET)); + assertEquals("my-id", out.get(OAuthAppConfig.KEY_CLIENT_ID)); + } + + @Test + public void buildSecrets_preserves_stored_clientSecret_when_mask_posted_back() { + final AppSecrets existing = AppSecrets.builder() + .withKey(DotAuthConstants.APP_KEY) + .withHiddenSecret(OAuthAppConfig.KEY_CLIENT_SECRET, "stored-secret") + .build(); + + final AppSecrets result = handler.buildSecrets( + Map.of(OAuthAppConfig.KEY_CLIENT_SECRET, "****"), + Optional.of(existing)); + + assertEquals("stored-secret", + result.getSecrets().get(OAuthAppConfig.KEY_CLIENT_SECRET).getString()); + } +} diff --git a/dotCMS/src/test/java/com/dotcms/auth/dotAuth/rest/handler/SamlProtocolHandlerTest.java b/dotCMS/src/test/java/com/dotcms/auth/dotAuth/rest/handler/SamlProtocolHandlerTest.java new file mode 100644 index 000000000000..5cd926959587 --- /dev/null +++ b/dotCMS/src/test/java/com/dotcms/auth/dotAuth/rest/handler/SamlProtocolHandlerTest.java @@ -0,0 +1,128 @@ +package com.dotcms.auth.dotAuth.rest.handler; + +import com.dotcms.auth.dotAuth.rest.DotAuthProtocol; +import com.dotcms.saml.DotSamlProxyFactory; +import com.dotcms.security.apps.AppSecrets; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +public class SamlProtocolHandlerTest { + + private final SamlProtocolHandler handler = new SamlProtocolHandler(); + + @Test + public void protocol_is_SAML() { + assertEquals(DotAuthProtocol.SAML, handler.protocol()); + } + + @Test + public void appKey_matches_DotSamlProxyFactory_constant() { + assertEquals(DotSamlProxyFactory.SAML_APP_CONFIG_KEY, handler.appKey()); + } + + @Test + public void secretKeys_match_dotsaml_config_yml_params() { + assertEquals( + List.of("enable", "idpName", "sPIssuerURL", "sPEndpointHostname", + "signatureValidationType", "idPMetadataFile", "publicCert", + "privateKey", "buttonParam"), + handler.secretKeys()); + } + + @Test + public void privateKey_is_hidden() { + assertTrue(handler.hiddenKeys().contains("privateKey")); + } + + @Test + public void enable_is_boolean() { + assertTrue(handler.booleanKeys().contains("enable")); + } + + @Test + public void maskedValues_replaces_privateKey_with_mask_and_unboxes_enable() { + final AppSecrets secrets = AppSecrets.builder() + .withKey(DotSamlProxyFactory.SAML_APP_CONFIG_KEY) + .withHiddenSecret("privateKey", "PEM-content") + .withSecret("enable", true) + .withSecret("idpName", "Okta") + .build(); + + final Map out = handler.maskedValues(secrets); + + assertEquals("****", out.get("privateKey")); + assertEquals(Boolean.TRUE, out.get("enable")); + assertEquals("Okta", out.get("idpName")); + } + + @Test + public void buildSecrets_preserves_stored_privateKey_when_mask_posted_back() { + final AppSecrets existing = AppSecrets.builder() + .withKey(DotSamlProxyFactory.SAML_APP_CONFIG_KEY) + .withHiddenSecret("privateKey", "stored-PEM") + .build(); + + final AppSecrets result = handler.buildSecrets( + Map.of("privateKey", "****", "idpName", "Okta"), + Optional.of(existing)); + + assertEquals("stored-PEM", result.getSecrets().get("privateKey").getString()); + assertEquals("Okta", result.getSecrets().get("idpName").getString()); + } + + @Test + public void maskedValues_passes_through_undeclared_keys_as_strings() { + final AppSecrets secrets = AppSecrets.builder() + .withKey(DotSamlProxyFactory.SAML_APP_CONFIG_KEY) + .withSecret("enable", true) + .withSecret("emailAttribute", "emailAddress") + .withSecret("rolesAttribute", "groups") + .build(); + + final Map out = handler.maskedValues(secrets); + + assertEquals("emailAddress", out.get("emailAttribute")); + assertEquals("groups", out.get("rolesAttribute")); + } + + @Test + public void buildSecrets_writes_undeclared_keys_as_strings() { + final AppSecrets result = handler.buildSecrets( + Map.of( + "idpName", "Okta", + "emailAttribute", "emailAddress", + "autoCreateUsers", "true"), + Optional.empty()); + + assertEquals("emailAddress", result.getSecrets().get("emailAttribute").getString()); + assertEquals("true", result.getSecrets().get("autoCreateUsers").getString()); + } + + @Test + public void buildSecrets_drops_undeclared_keys_with_empty_value() { + final AppSecrets result = handler.buildSecrets( + Map.of("idpName", "Okta", "emailAttribute", ""), + Optional.empty()); + + assertFalse(result.getSecrets().containsKey("emailAttribute")); + } + + @Test + public void buildSecrets_omits_previously_stored_undeclared_keys_not_resent() { + // Client is authoritative: if it doesn't send a previously-stored + // extra, we drop it. Matches how admins remove rows through the UI. + final AppSecrets existing = AppSecrets.builder() + .withKey(DotSamlProxyFactory.SAML_APP_CONFIG_KEY) + .withSecret("emailAttribute", "emailAddress") + .build(); + + final AppSecrets result = handler.buildSecrets( + Map.of("idpName", "Okta"), + Optional.of(existing)); + + assertFalse(result.getSecrets().containsKey("emailAttribute")); + } +} diff --git a/dotCMS/src/test/java/com/dotcms/auth/dotAuth/session/DotAuthSessionTest.java b/dotCMS/src/test/java/com/dotcms/auth/dotAuth/session/DotAuthSessionTest.java new file mode 100644 index 000000000000..8bbca124c317 --- /dev/null +++ b/dotCMS/src/test/java/com/dotcms/auth/dotAuth/session/DotAuthSessionTest.java @@ -0,0 +1,33 @@ +package com.dotcms.auth.dotAuth.session; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +/** Expiry-check semantics for the lightweight session record. */ +class DotAuthSessionTest { + + @Test + void expiresAtInFuture_isNotExpired() { + final long now = System.currentTimeMillis(); + final DotAuthSession session = new DotAuthSession("user-123", now, now + 60_000L); + assertFalse(session.isExpired(), "session expiring 60s from now must not be expired"); + } + + @Test + void expiresAtInPast_isExpired() { + final long now = System.currentTimeMillis(); + final DotAuthSession session = new DotAuthSession("user-123", now - 120_000L, now - 60_000L); + assertTrue(session.isExpired(), "session expiring 60s ago must be expired"); + } + + @Test + void accessors_roundTripValues() { + final DotAuthSession session = new DotAuthSession("user-42", 1_000L, 2_000L); + assertEquals("user-42", session.getUserId()); + assertEquals(1_000L, session.getCreatedAt()); + assertEquals(2_000L, session.getExpiresAt()); + } +} diff --git a/dotCMS/src/test/java/com/dotcms/auth/providers/oauth/BuildRolesStrategyTest.java b/dotCMS/src/test/java/com/dotcms/auth/providers/oauth/BuildRolesStrategyTest.java new file mode 100644 index 000000000000..1b9736313df8 --- /dev/null +++ b/dotCMS/src/test/java/com/dotcms/auth/providers/oauth/BuildRolesStrategyTest.java @@ -0,0 +1,48 @@ +package com.dotcms.auth.providers.oauth; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.dotcms.auth.providers.oauth.OAuthHelper.BuildRolesStrategy; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link BuildRolesStrategy#resolve(String)} — the parser that maps + * the dotAuth {@code buildRolesStrategy} value to one of the enum values, with + * {@link BuildRolesStrategy#ALL} as the safe fallback on unset / unrecognized input. + */ +class BuildRolesStrategyTest { + + @Test + void recognizedStrategies_allParse() { + for (final BuildRolesStrategy s : BuildRolesStrategy.values()) { + assertEquals(s, BuildRolesStrategy.resolve(s.name())); + } + } + + @Test + void lowercaseStrategy_isAccepted() { + assertEquals(BuildRolesStrategy.STATICADD, BuildRolesStrategy.resolve("staticadd")); + } + + @Test + void paddedStrategy_isTrimmed() { + assertEquals(BuildRolesStrategy.IDP, BuildRolesStrategy.resolve(" IDP ")); + } + + @Test + void unknownStrategy_fallsBackToAllRatherThanThrowing() { + // The whole point of the Try-based parse is to NOT bubble a misconfig up as a + // runtime exception during login — the safe fallback is ALL. + assertEquals(BuildRolesStrategy.ALL, BuildRolesStrategy.resolve("NOT_A_REAL_STRATEGY")); + } + + @Test + void blankStrategy_fallsBackToAll() { + assertEquals(BuildRolesStrategy.ALL, BuildRolesStrategy.resolve(" ")); + } + + @Test + void nullStrategy_fallsBackToAll() { + assertEquals(BuildRolesStrategy.ALL, BuildRolesStrategy.resolve(null)); + } +} diff --git a/dotCMS/src/test/java/com/dotcms/auth/providers/oauth/OAuthConstantsTest.java b/dotCMS/src/test/java/com/dotcms/auth/providers/oauth/OAuthConstantsTest.java new file mode 100644 index 000000000000..d1d1337531f1 --- /dev/null +++ b/dotCMS/src/test/java/com/dotcms/auth/providers/oauth/OAuthConstantsTest.java @@ -0,0 +1,34 @@ +package com.dotcms.auth.providers.oauth; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Arrays; +import org.junit.jupiter.api.Test; + +/** + * Sanity checks on {@link OAuthConstants} — primarily verifying that the core + * OAuth endpoints deliberately differ from the legacy plugin endpoints so that + * both can coexist. + */ +class OAuthConstantsTest { + + @Test + void corePaths_deliberatelyDifferFromLegacyPluginPaths() { + // Plugin used /api/v1/oauth2/callback; core uses /api/v1/oauth/callback + assertNotEquals("/api/v1/oauth2/callback", OAuthConstants.CALLBACK_PATH); + assertEquals("/api/v1/oauth/callback", OAuthConstants.CALLBACK_PATH); + + // Plugin used dotOAuthApp; core uses dotAuth (renamed from dotOAuth — edited by the dotAuth portlet) + assertNotEquals("dotOAuthApp", OAuthConstants.APP_KEY); + assertEquals("dotAuth", OAuthConstants.APP_KEY); + } + + @Test + void logoutPaths_coverAllExpectedEntryPoints() { + assertTrue(Arrays.asList(OAuthConstants.LOGOUT_PATHS).contains("/api/v1/logout")); + assertTrue(Arrays.asList(OAuthConstants.LOGOUT_PATHS).contains("/dotCMS/logout")); + assertTrue(Arrays.asList(OAuthConstants.LOGOUT_PATHS).contains("/dotAdmin/logout")); + } +} diff --git a/dotCMS/src/test/java/com/dotcms/auth/providers/oauth/OAuthHelperProfileSyncTest.java b/dotCMS/src/test/java/com/dotcms/auth/providers/oauth/OAuthHelperProfileSyncTest.java new file mode 100644 index 000000000000..dc0e14199476 --- /dev/null +++ b/dotCMS/src/test/java/com/dotcms/auth/providers/oauth/OAuthHelperProfileSyncTest.java @@ -0,0 +1,144 @@ +package com.dotcms.auth.providers.oauth; + +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.dotcms.auth.providers.oauth.OAuthHelper.BuildRolesStrategy; +import com.dotcms.auth.providers.oauth.provider.OAuthProvider; +import com.dotmarketing.business.APILocator; +import com.dotmarketing.business.UserAPI; +import com.dotmarketing.util.Config; +import com.liferay.portal.model.User; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +class OAuthHelperProfileSyncTest { + + private static final String ROLE_STRATEGY_PROP = "OAUTH_BUILD_ROLES_STRATEGY"; + + @Test + void existingUser_updatesNameFieldsFromProviderClaims() throws Exception { + final OAuthHelper helper = new OAuthHelper(); + final OAuthProvider provider = provider(); + final UserAPI userAPI = mock(UserAPI.class); + final User systemUser = mock(User.class); + final User user = mock(User.class); + + when(user.getUserId()).thenReturn("user-1"); + when(user.getEmailAddress()).thenReturn("new.name@example.com"); + when(user.getFirstName()).thenReturn("OldFirst"); + when(user.getLastName()).thenReturn("OldLast"); + when(user.isActive()).thenReturn(true); + when(userAPI.loadByUserByEmail(eq("new.name@example.com"), eq(systemUser), anyBoolean())) + .thenReturn(user); + + try (MockedStatic api = Mockito.mockStatic(APILocator.class); + MockedStatic cfg = Mockito.mockStatic(Config.class)) { + api.when(APILocator::getUserAPI).thenReturn(userAPI); + api.when(APILocator::systemUser).thenReturn(systemUser); + cfg.when(() -> Config.getIntProperty("dotcms.user.id.maxlength", 100)).thenReturn(100); + cfg.when(() -> Config.getStringProperty(ROLE_STRATEGY_PROP, BuildRolesStrategy.ALL.name())) + .thenReturn(BuildRolesStrategy.NONE.name()); + + helper.resolveOrProvisionUser(provider, null, Map.of( + "email", "new.name@example.com", + "email_verified", true, + "sub", "subject-1", + "given_name", "NewFirst", + "family_name", "NewLast"), null, true); + } + + verify(user).setFirstName("NewFirst"); + verify(user).setNickName("NewFirst"); + verify(user).setLastName("NewLast"); + verify(userAPI).save(user, systemUser, false); + } + + @Test + void existingUser_doesNotOverwriteNamesWhenClaimsAreAbsent() throws Exception { + final OAuthHelper helper = new OAuthHelper(); + final OAuthProvider provider = provider(); + final UserAPI userAPI = mock(UserAPI.class); + final User systemUser = mock(User.class); + final User user = mock(User.class); + + when(user.getUserId()).thenReturn("user-1"); + when(user.getEmailAddress()).thenReturn("unchanged@example.com"); + when(user.getFirstName()).thenReturn("ExistingFirst"); + when(user.getLastName()).thenReturn("ExistingLast"); + when(user.isActive()).thenReturn(true); + when(userAPI.loadByUserByEmail(eq("unchanged@example.com"), eq(systemUser), anyBoolean())) + .thenReturn(user); + + try (MockedStatic api = Mockito.mockStatic(APILocator.class); + MockedStatic cfg = Mockito.mockStatic(Config.class)) { + api.when(APILocator::getUserAPI).thenReturn(userAPI); + api.when(APILocator::systemUser).thenReturn(systemUser); + cfg.when(() -> Config.getIntProperty("dotcms.user.id.maxlength", 100)).thenReturn(100); + cfg.when(() -> Config.getStringProperty(ROLE_STRATEGY_PROP, BuildRolesStrategy.ALL.name())) + .thenReturn(BuildRolesStrategy.NONE.name()); + + helper.resolveOrProvisionUser(provider, null, Map.of( + "email", "unchanged@example.com", + "email_verified", true, + "sub", "subject-1"), null, true); + } + + verify(user, never()).setFirstName(Mockito.anyString()); + verify(user, never()).setNickName(Mockito.anyString()); + verify(user, never()).setLastName(Mockito.anyString()); + verify(userAPI, never()).save(user, systemUser, false); + } + + @Test + void unverifiedEmail_isNotUsedToMatchExistingAccount() throws Exception { + final OAuthHelper helper = new OAuthHelper(); + final OAuthProvider provider = provider(); + final UserAPI userAPI = mock(UserAPI.class); + final User systemUser = mock(User.class); + final User emailUser = mock(User.class); // the account that must NOT be matched + final User subjectUser = mock(User.class); // resolved via the namespaced subject instead + + when(subjectUser.getUserId()).thenReturn("subject-user"); + when(subjectUser.isActive()).thenReturn(true); + when(subjectUser.getFirstName()).thenReturn("First"); + when(subjectUser.getLastName()).thenReturn("Last"); + // If the gate were broken, this stub would hand back the wrong (email-matched) account. + when(userAPI.loadByUserByEmail(eq("victim@example.com"), eq(systemUser), anyBoolean())) + .thenReturn(emailUser); + // The issuer-namespaced subject is the trustworthy identity key. + when(userAPI.loadUserById(Mockito.anyString())).thenReturn(subjectUser); + when(userAPI.loadUserById(eq("subject-user"), eq(systemUser), anyBoolean())).thenReturn(subjectUser); + + try (MockedStatic api = Mockito.mockStatic(APILocator.class); + MockedStatic cfg = Mockito.mockStatic(Config.class)) { + api.when(APILocator::getUserAPI).thenReturn(userAPI); + api.when(APILocator::systemUser).thenReturn(systemUser); + cfg.when(() -> Config.getIntProperty("dotcms.user.id.maxlength", 100)).thenReturn(100); + cfg.when(() -> Config.getStringProperty(ROLE_STRATEGY_PROP, BuildRolesStrategy.ALL.name())) + .thenReturn(BuildRolesStrategy.NONE.name()); + + // No email_verified claim → the address must be ignored for existing-account matching. + final User resolved = helper.resolveOrProvisionUser(provider, null, Map.of( + "email", "victim@example.com", + "sub", "attacker-subject"), null, true); + + assertSame(subjectUser, resolved, "Unverified email must not match the email-keyed account"); + } + + verify(userAPI, never()).loadByUserByEmail(Mockito.anyString(), Mockito.any(), anyBoolean()); + } + + private static OAuthProvider provider() { + final OAuthProvider provider = mock(OAuthProvider.class); + when(provider.getProviderType()).thenReturn("OIDC"); + return provider; + } +} diff --git a/dotCMS/src/test/java/com/dotcms/auth/providers/oauth/OAuthSsrfGuardTest.java b/dotCMS/src/test/java/com/dotcms/auth/providers/oauth/OAuthSsrfGuardTest.java new file mode 100644 index 000000000000..590d06811527 --- /dev/null +++ b/dotCMS/src/test/java/com/dotcms/auth/providers/oauth/OAuthSsrfGuardTest.java @@ -0,0 +1,102 @@ +package com.dotcms.auth.providers.oauth; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.eq; + +import com.dotmarketing.util.Config; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +/** + * Unit coverage for {@link OAuthSsrfGuard}, the shared SSRF defense that vets every + * admin-configured and IdP-discovered URL before dotCMS fetches it server-side. The + * guard is security-critical and reachable from the unauthenticated headless exchange + * flow, so its branches (scheme allow-list, the {@code OAUTH_ALLOW_INSECURE_URLS} + * toggle, missing host, and the private/loopback/link-local/site-local resolution) + * are pinned here against silent regression. + * + *

Tests use literal IP addresses (loopback, link-local IMDS, RFC1918, public) so the + * resolution checks are deterministic and do not depend on live DNS. + */ +class OAuthSsrfGuardTest { + + /** Stub OAUTH_ALLOW_INSECURE_URLS to a known value for the duration of a test. */ + private static MockedStatic configWithInsecure(final boolean allowInsecure) { + final MockedStatic config = Mockito.mockStatic(Config.class); + config.when(() -> Config.getBooleanProperty(eq("OAUTH_ALLOW_INSECURE_URLS"), anyBoolean())) + .thenReturn(allowInsecure); + return config; + } + + @Test + void validateUrl_nullOrBlank_rejected() { + try (MockedStatic ignored = configWithInsecure(false)) { + assertNotNull(OAuthSsrfGuard.validateUrl(null)); + assertNotNull(OAuthSsrfGuard.validateUrl(" ")); + } + } + + @Test + void validateUrl_disallowedSchemes_rejected() { + try (MockedStatic ignored = configWithInsecure(false)) { + assertNotNull(OAuthSsrfGuard.validateUrl("file:///etc/passwd")); + assertNotNull(OAuthSsrfGuard.validateUrl("gopher://example.com/")); + assertNotNull(OAuthSsrfGuard.validateUrl("ftp://example.com/")); + } + } + + @Test + void validateUrl_httpRejectedWhenInsecureDisabled() { + try (MockedStatic ignored = configWithInsecure(false)) { + assertNotNull(OAuthSsrfGuard.validateUrl("http://idp.example.com/.well-known")); + } + } + + @Test + void validateUrl_httpAcceptedWhenInsecureEnabled() { + // With the toggle on, http is allowed AND the internal-host check is bypassed, + // so a public literal passes. (This conflation is itself a known low-sev finding; + // the test pins the current contract.) + try (MockedStatic ignored = configWithInsecure(true)) { + assertNull(OAuthSsrfGuard.validateUrl("http://8.8.8.8/.well-known")); + } + } + + @Test + void validateUrl_missingHost_rejected() { + try (MockedStatic ignored = configWithInsecure(false)) { + assertNotNull(OAuthSsrfGuard.validateUrl("https:///no-host")); + } + } + + @Test + void validateUrl_internalHosts_rejected() { + try (MockedStatic ignored = configWithInsecure(false)) { + assertNotNull(OAuthSsrfGuard.validateUrl("https://127.0.0.1/jwks"), "loopback"); + assertNotNull(OAuthSsrfGuard.validateUrl("https://169.254.169.254/latest/meta-data"), + "cloud-metadata link-local"); + assertNotNull(OAuthSsrfGuard.validateUrl("https://10.0.0.1/jwks"), "RFC1918 site-local"); + } + } + + @Test + void validateUrl_publicHttpsHost_accepted() { + try (MockedStatic ignored = configWithInsecure(false)) { + assertNull(OAuthSsrfGuard.validateUrl("https://8.8.8.8/jwks")); + } + } + + @Test + void isInternalHost_classifiesLiteralAddresses() { + assertTrue(OAuthSsrfGuard.isInternalHost("127.0.0.1"), "loopback"); + assertTrue(OAuthSsrfGuard.isInternalHost("169.254.169.254"), "link-local / IMDS"); + assertTrue(OAuthSsrfGuard.isInternalHost("10.0.0.1"), "RFC1918 site-local"); + assertTrue(OAuthSsrfGuard.isInternalHost("::1"), "IPv6 loopback"); + assertFalse(OAuthSsrfGuard.isInternalHost("8.8.8.8"), "public address"); + } +} diff --git a/dotCMS/src/test/java/com/dotcms/auth/providers/oauth/OAuthWebInterceptorTest.java b/dotCMS/src/test/java/com/dotcms/auth/providers/oauth/OAuthWebInterceptorTest.java new file mode 100644 index 000000000000..541d9de515ec --- /dev/null +++ b/dotCMS/src/test/java/com/dotcms/auth/providers/oauth/OAuthWebInterceptorTest.java @@ -0,0 +1,193 @@ +package com.dotcms.auth.providers.oauth; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.dotcms.auth.AuthAccessDeniedUtil; +import com.dotmarketing.util.WebKeys; +import com.liferay.portal.model.User; +import java.util.Arrays; +import java.util.List; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpSession; +import org.junit.jupiter.api.Test; + +/** + * Lightweight verification of {@link OAuthWebInterceptor}'s registration metadata. + * Full HTTP-flow tests are covered by integration tests; these guard against + * accidental removal of path coverage. + */ +class OAuthWebInterceptorTest { + + @Test + void getFilters_includesCallbackLogoutAndProtectedUrls() { + final OAuthWebInterceptor interceptor = new OAuthWebInterceptor(); + final List filters = Arrays.asList(interceptor.getFilters()); + + assertTrue(filters.contains(OAuthConstants.CALLBACK_PATH), + "Interceptor must claim the callback path: " + filters); + for (final String logoutPath : OAuthConstants.LOGOUT_PATHS) { + assertTrue(filters.contains(logoutPath), + "Interceptor must claim logout path " + logoutPath + "; filters: " + filters); + } + assertTrue(filters.stream().anyMatch(f -> f.contains("/dotAdmin/") || f.equals("/dotAdmin/")), + "Interceptor must claim at least one back-end protected URL: " + filters); + } + + @Test + void hasRequiredRole_backendLoginRejectsFrontendOnlyUser() { + assertFalse(AuthAccessDeniedUtil.hasRequiredRole(new TestUser(false, true, false), false)); + } + + @Test + void hasRequiredRole_frontendLoginAcceptsFrontendUser() { + assertTrue(AuthAccessDeniedUtil.hasRequiredRole(new TestUser(false, true, false), true)); + } + + @Test + void baseUrlFromRequest_omitsDefaultHttpsPort() { + // Standard 443 must NOT appear in the URL — the IdP redirect_uri is registered without it. + final HttpServletRequest request = mock(HttpServletRequest.class); + when(request.getScheme()).thenReturn("https"); + when(request.getServerName()).thenReturn("cms.example.com"); + when(request.getServerPort()).thenReturn(443); + assertEquals("https://cms.example.com", + OAuthWebInterceptor.baseUrlFromRequest(request)); + } + + @Test + void baseUrlFromRequest_omitsDefaultHttpPort() { + final HttpServletRequest request = mock(HttpServletRequest.class); + when(request.getScheme()).thenReturn("http"); + when(request.getServerName()).thenReturn("cms.example.com"); + when(request.getServerPort()).thenReturn(80); + assertEquals("http://cms.example.com", + OAuthWebInterceptor.baseUrlFromRequest(request)); + } + + @Test + void baseUrlFromRequest_includesNonDefaultPort() { + // localhost:8080 etc. keep the explicit port. + final HttpServletRequest request = mock(HttpServletRequest.class); + when(request.getScheme()).thenReturn("http"); + when(request.getServerName()).thenReturn("localhost"); + when(request.getServerPort()).thenReturn(8080); + assertEquals("http://localhost:8080", + OAuthWebInterceptor.baseUrlFromRequest(request)); + } + + @Test + void baseUrlFromRequest_blankServerNameReturnsNull() { + final HttpServletRequest request = mock(HttpServletRequest.class); + when(request.getScheme()).thenReturn("https"); + when(request.getServerName()).thenReturn(""); + assertEquals(null, OAuthWebInterceptor.baseUrlFromRequest(request)); + } + + @Test + void originalRequestUri_prefersServerSideRedirectAfterLogin() { + // Core stores the real page in REDIRECT_AFTER_LOGIN; it must win over the client referrer. + final HttpServletRequest request = mock(HttpServletRequest.class); + final HttpSession session = mock(HttpSession.class); + when(request.getSession(false)).thenReturn(session); + when(session.getAttribute(WebKeys.REDIRECT_AFTER_LOGIN)).thenReturn("/members/"); + when(request.getParameter(OAuthConstants.PARAM_REFERRER)).thenReturn("/other/"); + assertEquals("/members/", OAuthWebInterceptor.originalRequestUri(request)); + } + + @Test + void originalRequestUri_prefersReferrerParam() { + // /dotCMS/login?referrer=/members/ must resolve to /members/, not the login URL itself. + final HttpServletRequest request = mock(HttpServletRequest.class); + when(request.getParameter(OAuthConstants.PARAM_REFERRER)).thenReturn("/members/"); + assertEquals("/members/", OAuthWebInterceptor.originalRequestUri(request)); + } + + @Test + void originalRequestUri_fallsBackToUriWithQueryWhenNoReferrer() { + final HttpServletRequest request = mock(HttpServletRequest.class); + when(request.getParameter(OAuthConstants.PARAM_REFERRER)).thenReturn(null); + when(request.getAttribute(javax.servlet.RequestDispatcher.FORWARD_REQUEST_URI)).thenReturn(null); + when(request.getRequestURI()).thenReturn("/dotAdmin/"); + when(request.getQueryString()).thenReturn(null); + assertEquals("/dotAdmin/", OAuthWebInterceptor.originalRequestUri(request)); + } + + @Test + void sanitizeRedirect_allowsColonInQueryString() { + assertEquals("/dotAdmin/?contentType=Blog:detail", + OAuthWebInterceptor.sanitizeRedirect("/dotAdmin/?contentType=Blog:detail", "/dotAdmin/")); + } + + @Test + void sanitizeRedirect_rejectsColonInPath() { + assertEquals("/dotAdmin/", + OAuthWebInterceptor.sanitizeRedirect("/http:/evil.test", "/dotAdmin/")); + } + + @Test + void sanitizeRedirect_rejectsProtocolRelative() { + // //evil.test is treated by browsers as an absolute scheme-relative URL -> open redirect. + assertEquals("/dotAdmin/", + OAuthWebInterceptor.sanitizeRedirect("//evil.test", "/dotAdmin/")); + } + + @Test + void sanitizeRedirect_rejectsBackslashEscapedRoot() { + assertEquals("/dotAdmin/", + OAuthWebInterceptor.sanitizeRedirect("/\\evil.test", "/dotAdmin/")); + } + + @Test + void sanitizeRedirect_rejectsEmbeddedBackslash() { + assertEquals("/dotAdmin/", + OAuthWebInterceptor.sanitizeRedirect("/path\\x", "/dotAdmin/")); + } + + @Test + void sanitizeRedirect_nullReturnsFallback() { + assertEquals("/dotAdmin/", OAuthWebInterceptor.sanitizeRedirect(null, "/dotAdmin/")); + } + + @Test + void sanitizeRedirect_emptyReturnsFallback() { + assertEquals("/dotAdmin/", OAuthWebInterceptor.sanitizeRedirect("", "/dotAdmin/")); + } + + @Test + void sanitizeRedirect_allowsNormalRelativePath() { + assertEquals("/dotAdmin/path?x=y", + OAuthWebInterceptor.sanitizeRedirect("/dotAdmin/path?x=y", "/dotAdmin/")); + } + + private static final class TestUser extends User { + private final boolean backendUser; + private final boolean frontendUser; + private final boolean admin; + + private TestUser(final boolean backendUser, final boolean frontendUser, final boolean admin) { + this.backendUser = backendUser; + this.frontendUser = frontendUser; + this.admin = admin; + } + + @Override + public boolean isBackendUser() { + return backendUser; + } + + @Override + public boolean isFrontendUser() { + return frontendUser; + } + + @Override + public boolean isAdmin() { + return admin; + } + } +} diff --git a/dotCMS/src/test/java/com/dotcms/auth/providers/oauth/provider/GenericOAuth2ProviderTest.java b/dotCMS/src/test/java/com/dotcms/auth/providers/oauth/provider/GenericOAuth2ProviderTest.java new file mode 100644 index 000000000000..0ae51c8f06a0 --- /dev/null +++ b/dotCMS/src/test/java/com/dotcms/auth/providers/oauth/provider/GenericOAuth2ProviderTest.java @@ -0,0 +1,77 @@ +package com.dotcms.auth.providers.oauth.provider; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.dotmarketing.exception.DotRuntimeException; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link GenericOAuth2Provider}. Focuses on the pure URL-building + * logic that doesn't require HTTP mocks. + */ +class GenericOAuth2ProviderTest { + + private GenericOAuth2Provider newProvider() { + return new GenericOAuth2Provider( + "my-client-id", + "top-secret".toCharArray(), + "https://idp.example.com/authorize", + "https://idp.example.com/token", + "https://idp.example.com/userinfo", + null, + null, + null, + null); + } + + @Test + void buildAuthorizationUrl_includesClientIdStateAndEncodedScope() { + final GenericOAuth2Provider p = newProvider(); + final String url = p.buildAuthorizationUrl( + "the-state", null, null, + "https://callback/api/v1/oauth/callback", "email profile"); + + assertTrue(url.startsWith("https://idp.example.com/authorize?"), "URL should start with the configured authorization endpoint: " + url); + assertTrue(url.contains("response_type=code"), "missing response_type=code"); + assertTrue(url.contains("response_mode=query"), "missing response_mode=query pin"); + assertTrue(url.contains("client_id=my-client-id"), "missing client_id"); + assertTrue(url.contains("state=the-state"), "missing state"); + // scope "email profile" must be URL-encoded + assertTrue(url.contains("scope=email+profile") || url.contains("scope=email%20profile"), + "scope not URL-encoded in: " + url); + // redirect_uri is URL-encoded + assertTrue(url.contains("redirect_uri=https%3A%2F%2Fcallback%2Fapi%2Fv1%2Foauth%2Fcallback"), + "redirect_uri not URL-encoded in: " + url); + } + + @Test + void buildAuthorizationUrl_omitsScopeParamWhenScopeEmpty() { + final GenericOAuth2Provider p = newProvider(); + final String url = p.buildAuthorizationUrl("state", null, null, "https://cb", ""); + assertTrue(url.contains("state=state")); + // No scope param at all when empty + assertTrue(!url.contains("&scope="), "scope param should be omitted when empty: " + url); + } + + @Test + void constructor_rejectsMissingRequiredUrls() { + assertThrows(DotRuntimeException.class, () -> new GenericOAuth2Provider( + "id", "secret".toCharArray(), + null, // missing authorization URL + "https://idp.example.com/token", + "https://idp.example.com/userinfo", + null, null, null, null)); + assertThrows(DotRuntimeException.class, () -> new GenericOAuth2Provider( + "id", "secret".toCharArray(), + "https://idp.example.com/authorize", + null, // missing token URL + "https://idp.example.com/userinfo", + null, null, null, null)); + } + + @Test + void getProviderType_isOAuth2() { + assertTrue("OAuth2".equals(newProvider().getProviderType())); + } +} diff --git a/dotCMS/src/test/java/com/dotcms/auth/providers/oauth/provider/OIDCProviderClaimValidationTest.java b/dotCMS/src/test/java/com/dotcms/auth/providers/oauth/provider/OIDCProviderClaimValidationTest.java new file mode 100644 index 000000000000..16a9452a8c87 --- /dev/null +++ b/dotCMS/src/test/java/com/dotcms/auth/providers/oauth/provider/OIDCProviderClaimValidationTest.java @@ -0,0 +1,189 @@ +package com.dotcms.auth.providers.oauth.provider; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.dotmarketing.exception.DotRuntimeException; +import com.nimbusds.jwt.JWTClaimsSet; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link OIDCProvider#verifyIdTokenClaims} — the post-signature + * claim-set checks (iss / aud / exp / nonce / sub). Signature verification and the + * JWKS round-trip live in the public method; this test exercises the claim logic + * in isolation. + * + *

The iss-with-trailing-slash case is the regression test for the bug where a + * one-sided normalization (constructor only) rejected valid tokens emitted by + * Auth0 / Azure B2C tenants that include the slash. + */ +class OIDCProviderClaimValidationTest { + + private static final String ISSUER = "https://issuer.example.com"; + private static final String CLIENT_ID = "client-123"; + private static final String NONCE = "nonce-abc"; + private static final String SUBJECT = "user-42"; + + private static JWTClaimsSet.Builder validBuilder() { + return new JWTClaimsSet.Builder() + .issuer(ISSUER) + .audience(List.of(CLIENT_ID)) + .expirationTime(new Date(System.currentTimeMillis() + 60_000L)) + .claim("nonce", NONCE) + .subject(SUBJECT); + } + + @Test + void happyPath_passes() { + assertDoesNotThrow(() -> + OIDCProvider.verifyIdTokenClaims(validBuilder().build(), ISSUER, CLIENT_ID, NONCE)); + } + + @Test + void issWithTrailingSlash_matchesConfiguredIssuerWithoutSlash() { + // H-1 regression: the configured issuer is stripped of its trailing slash on + // construction; the token's iss must be normalized the same way before the + // equality check or validly-issued tokens from some IdPs get rejected. + final JWTClaimsSet claims = validBuilder().issuer(ISSUER + "/").build(); + assertDoesNotThrow(() -> + OIDCProvider.verifyIdTokenClaims(claims, ISSUER, CLIENT_ID, NONCE)); + } + + @Test + void issWrongIssuer_throws() { + final JWTClaimsSet claims = validBuilder().issuer("https://attacker.example").build(); + final DotRuntimeException ex = assertThrows(DotRuntimeException.class, () -> + OIDCProvider.verifyIdTokenClaims(claims, ISSUER, CLIENT_ID, NONCE)); + assertTrue(ex.getMessage().contains("iss mismatch"), ex.getMessage()); + } + + @Test + void audDoesNotContainClientId_throws() { + final JWTClaimsSet claims = validBuilder().audience(List.of("some-other-client")).build(); + final DotRuntimeException ex = assertThrows(DotRuntimeException.class, () -> + OIDCProvider.verifyIdTokenClaims(claims, ISSUER, CLIENT_ID, NONCE)); + assertTrue(ex.getMessage().contains("aud"), ex.getMessage()); + } + + @Test + void audContainsClientIdAmongMultipleWithMatchingAzp_passes() { + // OIDC Core §3.1.3.7: with multiple audiences the azp claim must be present and + // match our client_id. Multi-aud passes only with that proof of intended party. + final JWTClaimsSet claims = validBuilder() + .audience(List.of("other-client", CLIENT_ID, "third-client")) + .claim("azp", CLIENT_ID) + .build(); + assertDoesNotThrow(() -> + OIDCProvider.verifyIdTokenClaims(claims, ISSUER, CLIENT_ID, NONCE)); + } + + @Test + void audContainsClientIdAmongMultipleWithoutAzp_throws() { + final JWTClaimsSet claims = validBuilder() + .audience(List.of("other-client", CLIENT_ID, "third-client")) + .build(); + final DotRuntimeException ex = assertThrows(DotRuntimeException.class, () -> + OIDCProvider.verifyIdTokenClaims(claims, ISSUER, CLIENT_ID, NONCE)); + assertTrue(ex.getMessage().contains("azp"), ex.getMessage()); + } + + @Test + void audMissing_throws() { + final JWTClaimsSet claims = validBuilder().audience((List) null).build(); + assertThrows(DotRuntimeException.class, () -> + OIDCProvider.verifyIdTokenClaims(claims, ISSUER, CLIENT_ID, NONCE)); + } + + @Test + void expInThePast_throws() { + final JWTClaimsSet claims = validBuilder() + .expirationTime(new Date(System.currentTimeMillis() - 60_000L)) + .build(); + final DotRuntimeException ex = assertThrows(DotRuntimeException.class, () -> + OIDCProvider.verifyIdTokenClaims(claims, ISSUER, CLIENT_ID, NONCE)); + assertTrue(ex.getMessage().contains("expired"), ex.getMessage()); + } + + @Test + void expMissing_throws() { + final JWTClaimsSet claims = new JWTClaimsSet.Builder() + .issuer(ISSUER) + .audience(List.of(CLIENT_ID)) + .claim("nonce", NONCE) + .subject(SUBJECT) + .build(); + assertThrows(DotRuntimeException.class, () -> + OIDCProvider.verifyIdTokenClaims(claims, ISSUER, CLIENT_ID, NONCE)); + } + + @Test + void nonceMismatch_throws() { + final JWTClaimsSet claims = validBuilder().claim("nonce", "different-nonce").build(); + final DotRuntimeException ex = assertThrows(DotRuntimeException.class, () -> + OIDCProvider.verifyIdTokenClaims(claims, ISSUER, CLIENT_ID, NONCE)); + assertTrue(ex.getMessage().contains("nonce"), ex.getMessage()); + } + + @Test + void nonceMissing_throws() { + final JWTClaimsSet claims = new JWTClaimsSet.Builder() + .issuer(ISSUER) + .audience(List.of(CLIENT_ID)) + .expirationTime(new Date(System.currentTimeMillis() + 60_000L)) + .subject(SUBJECT) + .build(); + assertThrows(DotRuntimeException.class, () -> + OIDCProvider.verifyIdTokenClaims(claims, ISSUER, CLIENT_ID, NONCE)); + } + + @Test + void expectedNonceBlank_throws() { + assertThrows(DotRuntimeException.class, () -> + OIDCProvider.verifyIdTokenClaims(validBuilder().build(), ISSUER, CLIENT_ID, " ")); + } + + @Test + void subMissing_throws() { + final JWTClaimsSet claims = new JWTClaimsSet.Builder() + .issuer(ISSUER) + .audience(List.of(CLIENT_ID)) + .expirationTime(new Date(System.currentTimeMillis() + 60_000L)) + .claim("nonce", NONCE) + .build(); + final DotRuntimeException ex = assertThrows(DotRuntimeException.class, () -> + OIDCProvider.verifyIdTokenClaims(claims, ISSUER, CLIENT_ID, NONCE)); + assertTrue(ex.getMessage().contains("sub"), ex.getMessage()); + } + + @Test + void audEmptyList_throws() { + final JWTClaimsSet claims = validBuilder().audience(Collections.emptyList()).build(); + assertThrows(DotRuntimeException.class, () -> + OIDCProvider.verifyIdTokenClaims(claims, ISSUER, CLIENT_ID, NONCE)); + } + + @Test + void discoveredUrlWithPrivateHost_throws() { + final DotRuntimeException ex = assertThrows(DotRuntimeException.class, () -> + OIDCProvider.validateDiscoveredUrl("https://127.0.0.1/jwks", "jwks_uri", true)); + assertTrue(ex.getMessage().contains("internal/private"), ex.getMessage()); + } + + @Test + void discoveredUrlWithHttp_throwsByDefault() { + final DotRuntimeException ex = assertThrows(DotRuntimeException.class, () -> + OIDCProvider.validateDiscoveredUrl("http://issuer.example.com/token", "token_endpoint", true)); + assertTrue(ex.getMessage().contains("https"), ex.getMessage()); + } + + @Test + void missingRequiredDiscoveredUrl_throws() { + final DotRuntimeException ex = assertThrows(DotRuntimeException.class, () -> + OIDCProvider.validateDiscoveredUrl(null, "jwks_uri", true)); + assertTrue(ex.getMessage().contains("missing required"), ex.getMessage()); + } +} diff --git a/dotCMS/src/test/java/com/dotmarketing/business/portal/SerializationHelperTest.java b/dotCMS/src/test/java/com/dotmarketing/business/portal/SerializationHelperTest.java index dca9038b50f4..0cbffa1e5e12 100644 --- a/dotCMS/src/test/java/com/dotmarketing/business/portal/SerializationHelperTest.java +++ b/dotCMS/src/test/java/com/dotmarketing/business/portal/SerializationHelperTest.java @@ -35,7 +35,7 @@ public void testFromXmlFile() throws IOException { Logger.debug(SerializationHelperTest.class,"Loaded src/main/webapp/WEB-INF/portlet.xml:"+portletList.toString()); Logger.info(SerializationHelperTest.class, "Loaded portlet.xml: found: " + portletList.getPortlets().size() + " portlets"); assertNotNull("Deserialized PortletList should not be null", portletList); - assertEquals("PortletList should contain exactly 54 portlets", 54, portletList.getPortlets().size()); + assertEquals("PortletList should contain exactly 55 portlets", 55, portletList.getPortlets().size()); // Check for specific portlets assertTrue("PortletList should contain 'categories' portlet", diff --git a/justfile b/justfile index aed791fda9f3..54d628ccba00 100644 --- a/justfile +++ b/justfile @@ -1,5 +1,6 @@ -set positional-arguments := true import? 'justfile.local' + +set positional-arguments := true home_dir := env_var('HOME') # Introduction and Setup # If homebrew is not installed, run the following command: @@ -105,6 +106,18 @@ dev-run-fixed: -Dtomcat.ssl.port=8443 \ -Dmanagement.port=8090 +# Same as dev-run-fixed, plus OAUTH_ALLOW_INSECURE_URLS (for localhost callback URLs) +# and SecurityLogger at DEBUG so id_token validation failures are visible in the +# container logs. +dev-run-headless: + ./mvnw -pl :dotcms-core -Pdocker-start \ + -Dtomcat.port=8080 \ + -Dtomcat.ssl.port=8443 \ + -Dmanagement.port=8090 \ + -Dext.default.context.name=dev \ + -Dext.docker.dotcms-core.dev.dotcms.env.DOT_OAUTH_ALLOW_INSECURE_URLS=true \ + -Dext.docker.dotcms-core.dev.dotcms.env.DOT_LOGGER_CATEGORY_LEVEL_SECURITYLOGGER=DEBUG + # Polls /dotmgt/readyz until dotCMS is ready or 20 retries (100s) expire dev-wait-ready: curl --retry 20 --retry-delay 5 --retry-connrefused \ @@ -126,6 +139,49 @@ dev-stop: dev-clean-volumes: ./mvnw -pl :dotcms-core -Pdocker-clean-volumes +# Stops the headless-context containers started by dev-run-headless. Needed because +# those containers are named with context.name=dev, which the default dev-stop doesn't +# match, so plain dev-stop silently finds nothing. +dev-stop-headless: + ./mvnw -pl :dotcms-core -Pdocker-stop \ + -Dext.default.context.name=dev + +# Cleans up the headless-context Docker volumes. Required before a fresh +# dev-run-headless if you want a new starter.zip to actually import — dotCMS only +# loads the starter on a fresh DB. +dev-clean-volumes-headless: + ./mvnw -pl :dotcms-core -Pdocker-clean-volumes \ + -Dext.default.context.name=dev + +# Build frontend + backend delta, then replace just the dotCMS app container. +# DB + ES containers stay running so startup is fast and data is preserved. +reload-headless: + ./mvnw install -pl :dotcms-core-web,:dotcms-core -am -DskipTests + docker stop dotbuild_dotcms-core_dev_dotcms && docker rm dotbuild_dotcms-core_dev_dotcms + ./mvnw -pl :dotcms-core -Pdocker-start \ + -Dtomcat.port=8080 \ + -Dtomcat.ssl.port=8443 \ + -Dmanagement.port=8090 \ + -Dext.default.context.name=dev \ + -Dext.docker.dotcms-core.dev.dotcms.env.DOT_OAUTH_ALLOW_INSECURE_URLS=true \ + -Dext.docker.dotcms-core.dev.dotcms.env.DOT_LOGGER_CATEGORY_LEVEL_SECURITYLOGGER=DEBUG + +# Nuke all caches and rebuild everything from scratch, then replace the dotCMS container. +# DB + ES stay running. Use when incremental builds can't be trusted. +full-reload-headless: build-no-cache + docker stop dotbuild_dotcms-core_dev_dotcms && docker rm dotbuild_dotcms-core_dev_dotcms + ./mvnw -pl :dotcms-core -Pdocker-start \ + -Dtomcat.port=8080 \ + -Dtomcat.ssl.port=8443 \ + -Dmanagement.port=8090 \ + -Dext.default.context.name=dev \ + -Dext.docker.dotcms-core.dev.dotcms.env.DOT_OAUTH_ALLOW_INSECURE_URLS=true \ + -Dext.docker.dotcms-core.dev.dotcms.env.DOT_LOGGER_CATEGORY_LEVEL_SECURITYLOGGER=DEBUG + +# Full reset for the headless stack: stop containers, wipe volumes, start fresh with +# whatever starter.zip is currently staged in dotCMS/target/starter/. +dev-reset-headless: dev-stop-headless dev-clean-volumes-headless dev-run-headless + # Starts the dotCMS application in a Tomcat container on port 8087, running in the foreground dev-tomcat-run port="8087": ./mvnw -pl :dotcms-core -Ptomcat-run -Pdebug -Dservlet.port={{ port }}