Liferay–Keycloak SSO Integration via OpenID Connect

Liferay–Keycloak SSO Integration via OpenID Connect

 


Define the architecture, configuration, and rollout plan for delegating authentication in a Liferay portal to Keycloak using OpenID Connect (OIDC), with Liferay acting as the OpenID Relying Party (RP) and Keycloak as the central Identity Provider (IdP).


●       Covers Keycloak realm/client setup, Liferay OIDC configuration, user provisioning, and testing.

●       Does not cover Keycloak clustering/HA, LDAP/AD federation into Keycloak, or Liferay theme/UI customization of the login screen (call out separately if needed).

Open Questions (to confirm before implementation)

#

Question

Why it matters

1

Liferay edition/version — CE 7.4, DXP 7.3, or DXP 7.4?

DXP has additional admin UI options and support SLAs; CE 7.4 config is assumed below unless told otherwise

2

Single realm shared across environments, or per-environment (dev/stage/prod) Keycloak realms?

Affects client registration count and secret rotation process

3

Is role/group mapping between Keycloak and Liferay required (e.g. Keycloak realm roles → Liferay Site Roles)?

Needs custom claim mapping in Keycloak + Liferay OIDC role mapper

4

Will Liferay's local login form remain available alongside OIDC, or is OIDC the only sign-in path?

Determines whether “Enable OpenID Connect” fully replaces the default form or just adds a button

5

New users only, or does this need to migrate/merge existing Liferay accounts by email match?

Liferay auto-provisions new accounts on first OIDC login; existing accounts need a matching-strategy decision

 

Architecture Overview

Key design point: Liferay is registered as a confidential client, meaning the authorization-code → token exchange happens server-to-server (Liferay backend to Keycloak token endpoint), not in the browser. The client secret never reaches the browser.

Component Design

   Keycloak — Client Registration

Setting

Value

Realm

liferay-kc-demo (dedicated realm recommended over master)

Client ID

liferay-portal-client

Client type

OpenID Connect

Access Type

ON (confidential)

Standard Flow Enabled (Authorization Code)

Enabled

Direct access grants

Enabled (not needed for browser SSO)

Root URL

https://<liferay-domain> in my case ->(http://localhost:9070)

Valid redirect URIs

https://<liferay-domain>/* (narrow to the actual callback path in production, e.g. /c/portal/login)

Web origins

https://<liferay-domain>

 

 

 

 

User attribute prerequisite: every Keycloak user intended to log into Liferay must have first name, last name, and email populated — Liferay's OIDC auto-provisioning fails without these.

  Keycloak — Discovery Metadata

Discovery document location:

https://<keycloak-domain>/auth/realms/<realm>/.well-known/openid-configuration

This exposes authorization_endpoint, token_endpoint, jwks_uri, userinfo_endpoint, and issuer. Preferred integration path is to feed this single URL to Liferay (Option A below) rather than hand-copying each endpoint.

  Liferay — OIDC Provider Configuration

Location: Control Panel → Instance Settings → Security → SSO → OpenID Connect

 

Field

Value

Enabled

Yes

Provider Name

Keycloak (label shown on the sign-in screen)

Client ID

liferay-portal-client

Client Secret

(from Keycloak Credentials tab)

Scopes

openid profile email — profile is mandatory for Liferay to pull name/user details

 

Endpoint configuration — two options

●       Option A (preferred): Discovery Endpoint. Paste the .well-known/openid-configuration URL(localhost:9093/auth/realms/liferay-kc-demo/.well-known/openid-configuration); Liferay auto-fetches authorization/token/jwks/userinfo endpoints. Lower maintenance — survives Keycloak endpoint changes automatically.

●       Option B (manual): explicit endpoints, only if the Liferay server cannot reach the discovery URL outbound (e.g. network/firewall restriction):

○       Authorization: https://<keycloak-domain>/realms/<realm>/protocol/openid-connect/auth

○       Token: https://<keycloak-domain>/realms/<realm>/protocol/openid-connect/token

○       Issuer: https://<keycloak-domain>/realms/<realm>

○       JWKS: https://<keycloak-domain>/realms/<realm>/protocol/openid-connect/certs

○       Userinfo: https://<keycloak-domain>/realms/<realm>/protocol/openid-connect/userinfo

 User Provisioning Behavior

●       First successful OIDC login creates a local Liferay User record, populated from the profile/email claims.

●       Subsequent logins reuse the same record, matched by the Keycloak subject/email depending on Liferay's configured matching strategy.

●       If open question #5 (existing account migration) applies, this needs explicit handling — Liferay does not merge by email automatically in all versions; confirm behavior on the target version before go-live.

 Role/Claim Mapping (if in scope — see open question #3)

If Keycloak realm roles need to drive Liferay Site Roles/Organizations:

●       Add a Keycloak Client Scope with a role-list mapper, add it to the client's assigned scopes.

●       On the Liferay side, this typically requires either a custom OpenIdConnectUserInfoProcessor / portal property extension (DXP) or a Liferay-side event listener that reads the roles claim from the ID token on login and syncs Site Role membership. This is the piece most likely to need custom code — flag for a follow-up design note once question #3 is answered.

 Security Considerations

●       Client is confidential — secret must be stored in Liferay's config (not source control) and rotated on the same cadence as other service credentials.

●       Restrict Valid Redirect URIs to the exact callback path in production rather than a wildcard, to reduce open-redirect risk.

●       Enforce HTTPS on both Liferay and Keycloak endpoints — no plaintext token exchange.

●       Consider token lifetime / session timeout alignment between Keycloak (SSO session max) and Liferay (portal session timeout) so users aren't logged out of one but not the other.

●       If DPP2's existing Keycloak instance is reused for this realm, keep the Liferay client isolated in its own realm or client to avoid scope creep with payment-platform credentials.

 Testing / Validation Plan

●       Unit config check — verify discovery URL resolves and returns expected endpoints from the Liferay server's network (not just a browser).

●       Happy path — new Keycloak user, first login, incognito browser: confirm redirect to Keycloak, successful auth, redirect back, and correct local user auto-provisioned in Liferay with name/email populated.

●       Repeat login — same user logs in a second time: confirm no duplicate account is created.

●       Negative case — Keycloak user missing first/last name or email: confirm the expected failure behavior and error messaging, so it can be documented for support.

●       Role mapping (if in scope) — confirm a Keycloak role change is reflected in Liferay Site Role membership on next login.

●       Fallback — if local Liferay login must remain available (open question #4), confirm it still works alongside the OIDC button.

 Rollout Plan

Phase

Action

1

Stand up/confirm Keycloak realm and register the Liferay client in a non-prod environment

2

Configure Liferay OIDC in staging, run the test plan (Section 7)

3

Confirm role-mapping approach (if applicable) with a small pilot group

4

Document rollback plan (disable OIDC toggle reverts to local login without data loss)

5

Promote configuration to production with narrowed redirect URIs and rotated secret

 

 Assumptions

●       Target is Liferay 7.4 (CE or DXP — pending confirmation, see open question #1); steps above are version-agnostic for OIDC but admin UI paths may shift slightly between CE and DXP.

●       A dedicated Keycloak realm (not master) will be used for this integration.

●       Outbound HTTPS from the Liferay server to the Keycloak discovery/token/JWKS endpoints is permitted by network policy.

Read more →

Workflow definition: 2-Step-Approver · Liferay 7.4 Kaleo workflow schema

 

Kaleo Workflow XML Definition

The complete XML definition for the 2-Step Approver workflow is shown below. This file is imported into Liferay DXP via Control Panel → Process Builder → Workflows → Add (+) → Source tab.

 

<?xml version="1.0"?>

<workflow-definition   xmlns="urn:liferay.com:liferay-workflow_7.4.0"  xsi:schemaLocation="urn:liferay.com:liferay-workflow_7.4.0 http://www.liferay.com/dtd/liferay-workflow-definition_7_4_0.xsd"

            xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">

            <name>2-Step-Approver</name>

            <description>Two-step approval workflow.</description>

            <version>1</version>

            <!-- ══ INITIAL STATE ══ -->

            <state>

                        <name>created</name>

                        <initial>true</initial>

                        <transitions>

                                    <transition>

                                                <name>review</name>

                                                <target>step-1-review</target>

                                                <default>true</default>

                                    </transition>

                        </transitions>

            </state>

            <!-- ══ TERMINAL STATE ══ -->

            <state>

                        <name>approved</name>

                        <metadata>

                                    <![CDATA[{"terminal": true}]]>

                        </metadata>

                        <actions>

                                    <action>

                                                <name>approve</name>

                                                <status>0</status>

                                                <execution-type>onEntry</execution-type>

                                    </action>

                        </actions>

            </state>

            <!-- ══ UPDATE TASK (Rejection target — assigned back to original author) ══ -->

            <task>

                        <name>update</name>

                        <actions>

                                    <action>

                                                <name>deny</name>

                                                <status>4</status>

                                                <priority>1</priority>

                                                <execution-type>onAssignment</execution-type>

                                    </action>

                                    <action>

                                                <name>pending</name>

                                                <status>1</status>

                                                <priority>2</priority>

                                                <execution-type>onAssignment</execution-type>

                                    </action>

                                    <notification>

                                                <name>Creator Modification Notification</name>

                                                <template>

                                                            <![CDATA[Your submission was rejected by ${userName}, please modify and resubmit.]]>

                                                </template>

                                                <template-language>freemarker</template-language>

                                                <notification-type>email</notification-type>

                                                <notification-type>user-notification</notification-type>

                                                <recipients receptionType="to">

                                                            <user></user>

                                                </recipients>

                                                <execution-type>onAssignment</execution-type>

                                    </notification>

                        </actions>

                        <assignments>

                                    <user></user>

                        </assignments>

                        <transitions>

                                    <transition>

                                                <name>resubmit</name>

                                                <target>step-1-review</target>

                                                <default>true</default>

                                    </transition>

                        </transitions>

            </task>

 

            <!-- ══ STEP 1: REVIEW TASK ══ -->

            <task>

                        <name>step-1-review</name>

                        <actions>

                                    <action>

                                                <name>pending</name>

                                                <status>1</status>

                                                <execution-type>onAssignment</execution-type>

                                    </action>

                                    <notification>

                                                <name>Step 1 Review Notification</name>

                                                <template>

                                                            <![CDATA[${userName} sent you a ${entryType} for review in the workflow.]]>

                                                </template>

                                                <template-language>freemarker</template-language>

                                                <notification-type>email</notification-type>

                                                <notification-type>user-notification</notification-type>

                                                <recipients receptionType="to">

                                                            <assignees></assignees>

                                                </recipients>

                                                <execution-type>onAssignment</execution-type>

                                    </notification>

                        </actions>

                        <assignments>

                                    <roles>

                                                <role>

                                                            <role-type>regular</role-type>

                                                            <name>Reviewer</name>

                                                </role>

                                    </roles>

                        </assignments>

                        <transitions>

                                    <transition>

                                                <name>approve</name>

                                                <target>step-2-final-approval</target>

                                                <default>true</default>

                                    </transition>

                                    <transition>

                                                <name>reject</name>

                                                <target>update</target>

                                                <default>false</default>

                                    </transition>

                        </transitions>

            </task>

 

            <!-- ══ STEP 2: FINAL APPROVAL TASK ══ -->

            <task>

                        <name>step-2-final-approval</name>

                        <actions>

                                    <action>

                                                <name>pending</name>

                                                <status>1</status>

                                                <execution-type>onAssignment</execution-type>

                                    </action>

                                    <notification>

                                                <name>Step 2 Final Approval Notification</name>

                                                <template>

                                                            <![CDATA[${userName} sent you a ${entryType} for final approval in the workflow.]]>

                                                </template>

                                                <template-language>freemarker</template-language>

                                                <notification-type>email</notification-type>

                                                <notification-type>user-notification</notification-type>

                                                <recipients receptionType="to">

                                                            <assignees></assignees>

                                                </recipients>

                                                <execution-type>onAssignment</execution-type>

                                    </notification>

                        </actions>

                        <assignments>

                                    <roles>

                                                <role>

                                                            <role-type>regular</role-type>

                                                            <name>Approver</name>

                                                </role>

                                    </roles>

                        </assignments>

                        <transitions>

                                    <transition>

                                                <name>approve</name>

                                                <target>approved</target>

                                                <default>true</default>

                                    </transition>

                                    <transition>

                                                <name>reject</name>

                                                <target>update</target>

                                                <default>false</default>

                                    </transition>

                        </transitions>

            </task>

 

</workflow-definition>

This guide explains every XML element used in the attached 2-Step-Approver workflow definition, one tag at a time, with the exact values from the file and what each one does at runtime. It's organized to be read top-to-bottom as a reference, or used to look up a single tag.

1. What This Workflow Does

Before going tag-by-tag, here is the routing this specific definition implements:

Node

Type

Assigned to

Where it can go next

created

state (initial)

— (no assignee)

Automatically moves to step-1-review

step-1-review

task

Role: Reviewer

approve → step-2-final-approval  |  reject → update

step-2-final-approval

task

Role: Approver

approve → approved (terminal)  |  reject → update

update

task

Original submitter (empty <user/>)

resubmit → step-1-review

approved

state (terminal)

— (no assignee)

End of workflow; sets content status to Approved

 

In short: content enters the workflow, a Reviewer checks it, an Approver gives final sign-off, and if either one rejects it, it's routed back to whoever originally submitted it for correction and resubmission — which loops back to Step 1.

2. Root & Header Tags

Tag

Value in this file

Purpose

<workflow-definition>

root element

The document root. The xmlns/xsi:schemaLocation attributes bind this file to the Liferay 7.4.0 Kaleo workflow XSD, so Liferay validates the structure against that schema on import.

<name>

2-Step-Approver

Display name shown in Control Panel → Workflow → Definitions, and in the dropdown when assigning this workflow to a content type.

<description>

Two-step approval workflow.

One-line summary shown next to the name in the admin UI. Purely descriptive — has no effect on execution.

<version>

1

Liferay's internal revision counter for this definition name. Every time you republish a definition under the same name, Liferay auto-increments this; a brand-new definition always starts at 1.

3. State Nodes — <state>

A <state> is a routing point with no owner. Nothing appears in anyone's task inbox for a state — the workflow just passes through it (or, for a terminal state, stops there). This file has exactly two: created and approved.

Tag

Seen on

Purpose

<initial>true</initial>

created

Marks this as the workflow's entry point. Every definition must have exactly one <state> with <initial>true</initial> — this is where new content lands the moment it enters workflow.

<metadata>

approved

Free-form CDATA JSON consumed by the graphical Workflow Designer in the admin UI. {"terminal": true} tells the designer to draw this node as an end-point. It is a UI hint only — it does not stop the engine from adding transitions out of it if you wanted to.

<transitions>

created

Wraps the outgoing <transition> elements for this node — see Section 5.

4. Task Nodes — <task>

A <task> is a node that requires a human decision — it shows up in a real user's (or role's) Assigned To Me list until someone acts on it. This file has three: step-1-review (Reviewer role), step-2-final-approval (Approver role), and update (routed back to whoever submitted the content).

4.1 <assignments>

Declares who a task is routed to. Liferay supports several assignment strategies; this file uses two of them:

Tag

Seen on

Purpose

<user></user>

update

An empty <user/> element is Liferay's shorthand for "the user who originally submitted this content into the workflow" — not a specific named account. This is what routes a rejected item back to its author instead of to a fixed person.

<roles>

step-1-review, step-2-final-approval

Wraps one or more <role> elements. Assigns the task to every user holding that role — any one of them can claim and complete it, rather than one fixed individual.

<role><role-type>

regular

Scope of the role lookup. regular means a global Liferay Role (defined once under Users & Organizations → Roles), independent of any specific site — as opposed to site or organization roles, which are scoped to a particular site/org.

<role><name>

Reviewer / Approver

The exact role name Liferay looks up at runtime. These roles must already exist in the portal (Regular Role) before this workflow is assigned to content, or task assignment will fail silently.

4.2 <notification> (inside <actions> on a task)

Covered in full in Section 7, since the same notification tags are used identically on states, tasks, and actions.

5. Transitions — <transitions> / <transition>

Every <state> or <task> that isn't terminal has a <transitions> block listing the paths a user (or the engine) can take out of it.

Tag

Example from file

Purpose

<transitions>

wraps 1+ <transition>

Container for every possible outward path from this node. A task with two <transition> children (like step-1-review's approve/reject) shows the assignee two action buttons.

<transition><name>

approve, reject, resubmit, review

The label for this path. On a task, this becomes the literal button text/action name the assignee clicks. On a state, it's just the internal transition name (states auto-fire, so there's no button).

<target>

e.g. step-2-final-approval

The name of the <state> or <task> this transition leads to. Must exactly match another node's <name> in the file.

<default>

true / false

Marks the primary transition on a node. When a node has multiple transitions, exactly one should be true — that's the one triggered by the definition's default action (and rendered as the primary button in the UI); others are secondary/alternate actions.

Concrete example — step-1-review's two transitions:

●       approve → target: step-2-final-approval, default: true — the primary path when the Reviewer approves.

●       reject → target: update, default: false — the alternate path when the Reviewer sends it back for correction.

6. Actions — <actions> / <action>

An <action> is something the engine does automatically at a defined point in a node's lifecycle — most commonly, changing the content's workflow status. Every <state> and <task> in this file has an <actions> wrapper containing one or more of these.

Tag

Purpose

<actions>

Container for one or more <action> and/or <notification> elements attached to a state or task.

<action><name>

A label for the action (e.g. "approve", "deny", "pending") — for readability only; it does not have to match a transition name.

<action><status>

The numeric WorkflowConstants status code applied to the content when this action fires. See the status code table below — this is the actual mechanism that marks content Approved, Denied, or Pending in Liferay.

<action><execution-type>

When the action fires — see the execution-type table below.

<action><priority>

Only needed when a node has multiple actions with the same execution-type. Lower number runs first. Used on the update task (see 6.3).

6.1 Status codes used in this file

These come from Liferay's WorkflowConstants class (com.liferay.portal.kernel.workflow.WorkflowConstants) — the numbers are fixed by Liferay core, not by this workflow file:

Value

Constant

Used on

0

STATUS_APPROVED

The approved state's action — marks content as live/published.

1

STATUS_PENDING

The pending action on step-1-review, step-2-final-approval, and (2nd) on update — content is still mid-workflow.

4

STATUS_DENIED

The deny action on update — briefly marks content Denied so any "denied" notification/rule fires, immediately before it's flipped back to Pending.

6.2 <execution-type> values used in this file

Value

Fires when...

Used on

onEntry

The moment the workflow enters this node.

The approve action on the approved state — status flips to Approved as soon as the workflow arrives there.

onAssignment

Every time this task is assigned (or re-assigned) to a user/role — including the very first assignment and any resubmission.

All pending/deny actions on the three tasks, and all four <notification> blocks.

6.3 Why the update task has two actions

This is a standard Liferay pattern (also used in the built-in Single Approver definition): when a task is rejected, the update task first sets status to Denied (priority 1), then immediately sets it to Pending (priority 2).

●       The brief Denied status is what triggers Liferay's "content was denied" notification behavior for the submitter.

●       It's then moved to Pending because the item is not actually finished — it's sitting in an active task (update) waiting on the author to fix and resubmit it, not permanently rejected.

●       The <priority> tag is what guarantees deny runs before pending, since both share execution-type onAssignment.


 

7. Notifications — <notification>

A <notification> sends a message when its parent action's execution-type condition is met. This file defines three, all firing onAssignment (i.e. every time the relevant task is handed to someone).

Tag

Purpose

<notification><name>

Internal label for the notification, shown to administrators only (e.g. "Step 1 Review Notification"). Not seen by end users.

<template> (CDATA)

The actual message body. Written in FreeMarker syntax here, using workflow context variables — ${userName} (who triggered it) and ${entryType} (the type of content, e.g. "Web Content Article").

<template-language>

Which templating engine parses <template>. All three notifications use freemarker (Liferay also supports plain text).

<notification-type>

Delivery channel. This file repeats the tag to send both email and user-notification (Liferay's in-app bell/notification panel) for every notification — recipients get both.

<recipients receptionType="to">

Who receives it. receptionType="to" marks these as primary recipients (Liferay also supports cc/bcc).

<recipients><user></user>

Empty <user/> here means the same thing it means in <assignments>: the original content submitter. Used on the update task's notification, so the author is told why their submission was rejected.

<recipients><assignees></assignees>

Sends to whoever the task was just assigned to — i.e. every member of the Reviewer or Approver role, on the two review notifications.

<notification><execution-type>

Same enumeration as action execution-type. onAssignment on all three — the notification fires every time the task is (re)assigned, not just the first time.

Message text used in this file, for reference:

●       Step 1 Review Notification: "${userName} sent you a ${entryType} for review in the workflow."

●       Step 2 Final Approval Notification: "${userName} sent you a ${entryType} for final approval in the workflow."

●       Creator Modification Notification: "Your submission was rejected by ${userName}, please modify and resubmit."

8. Quick Reference — All Tags, A→Z

Tag

Appears on

One-line meaning

<action>

state, task

One automatic effect (status change) fired at a lifecycle point.

<actions>

state, task

Container for that node's <action>/<notification> children.

<assignees>

recipients

Notification recipient = whoever the task was just assigned to.

<assignments>

task

Who a task is routed to.

<default>

transition

Marks the primary transition/button on a node.

<description>

workflow-definition

Admin-UI summary of the whole workflow.

<execution-type>

action, notification

When the action/notification fires (onEntry, onAssignment).

<initial>

state

Marks the workflow's single entry-point state.

<metadata>

state

Designer-UI hint (e.g. terminal node styling); not engine logic.

<name>

workflow-definition, state, task, action, transition, role, notification

The identifier/label for whichever element it's nested in.

<notification>

actions

An automatic email / in-app message.

<notification-type>

notification

Delivery channel: email and/or user-notification.

<priority>

action

Execution order when a node has multiple same-type actions.

<recipients>

notification

Who receives the notification, and how (to/cc/bcc).

<role>

roles

One role a task is assigned to.

<role-type>

role

Scope of the role: regular, site, or organization.

<roles>

assignments

Wraps one or more <role> assignees.

<state>

workflow-definition

An unowned routing node (no task inbox entry).

<status>

action

The WorkflowConstants status code to apply (0/1/4 in this file).

<target>

transition

The node this transition leads to.

<task>

workflow-definition

An owned routing node — appears in a user/role's task inbox.

<template>

notification

The notification message body (CDATA, FreeMarker).

<template-language>

notification

Templating engine for <template> — freemarker here.

<transition>

transitions

One possible outward path from a state or task.

<transitions>

state, task

Container for a node's outgoing <transition> elements.

<user>

assignments, recipients

Empty = "the original content submitter", not a fixed account.

<version>

workflow-definition

Liferay's auto-incremented revision number for this definition.

<workflow-definition>

root

Root element; binds the file to the Kaleo 7.4.0 XSD.

Read more →