How microservice Client Extensions work at runtime
Once your CX is deployed, every user-agent type — Object Action, Workflow Action, Notification Type, Object Validation Rule, Object Entry Manager, CAPTCHA, all Commerce integrations — walks the same flowchart at request time. The scenario switcher above the diagram traces one of three real outcomes:
- Invalid agent token — the token fails signature, issuer, or expiry checks. The microservice returns 401 immediately and the cycle ends. No business logic, no Headless call.
- Call Headless with the agent token — token is valid, need to update Liferay, and the acting user has enough scope. Reuse the same agent token to hit any
/o/headless-*/v1.0/...endpoint on their behalf. - Call Headless with a server token — token is valid, need to update Liferay, but the operation requires higher scope than the acting user has. Mint a server token via
client_credentialsagainst a registeredoAuthApplicationHeadlessServer, then call Headless with it.
Switch scenarios above the diagram; the packet re-routes through the same flowchart. Diamonds are decision points, rectangles are process steps, pills are terminals. Hover any node to tilt in 3D.
A logged-in user clicks an Object Action button, or the workflow engine hits a transition that maps to your Workflow Action.
Inside your handler
The three snippets below map to the three modes in the diagram. All of them live inside the same POST handler — pick the pattern that matches what the current request needs to do.
import express, { Request, Response } from 'express';
import { createRemoteJWKSet, jwtVerify } from 'jose';
const LIFERAY = process.env.LIFERAY_URL!;
const JWKS = createRemoteJWKSet(new URL(`${LIFERAY}/o/oauth2/jwks`));
const app = express();
app.use(express.json({ limit: '2mb' }));
app.post('/actions/my-action', async (req: Request, res: Response) => {
// Grab the agent token Liferay attached.
const auth = req.header('authorization') ?? '';
const agentToken = auth.replace(/^Bearer\s+/i, '');
if (!agentToken) return res.status(401).json({ error: 'no token' });
// Verify signature + issuer. Any failure = 401 rejected.
let claims;
try {
({ payload: claims } = await jwtVerify(agentToken, JWKS, {
issuer: LIFERAY,
}));
} catch (err) {
return res.status(401).json({ error: 'invalid token', detail: String(err) });
}
const actingUserId = claims.sub;
// ... run your business logic here, then respond.
res.json({ status: 'success', actingUserId });
});
app.listen(Number(process.env.PORT ?? 8080));// After validating: re-attach the SAME agent token to a Headless call.
// Liferay runs it as the acting user — their site scope + permissions apply.
// No extra token generation.
const asUser = await fetch(
`${LIFERAY}/o/headless-admin-user/v1.0/my-user-account`,
{ headers: { Authorization: `Bearer ${agentToken}` } },
);
if (!asUser.ok) {
// 403 = the acting user isn't allowed to see it. Normal ACL response.
return res.status(asUser.status).json({ error: 'headless failed' });
}
const me = await asUser.json();
res.json({ status: 'success', me });// Only run this branch when the operation exceeds the acting user's scope.
// client_id + client_secret come from a server-type OAuth application
// you registered with Liferay. Keep them as env vars.
const tokenRes = await fetch(`${LIFERAY}/o/oauth2/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: process.env.SERVER_CLIENT_ID!,
client_secret: process.env.SERVER_CLIENT_SECRET!,
}),
});
if (!tokenRes.ok) throw new Error(`token ${tokenRes.status}`);
const { access_token: serverToken } = await tokenRes.json();
// Cache it. Refresh when it expires. Don't ask for a new one every request.// With the server token in hand, call any Headless endpoint scoped to
// what the server OAuth app was granted. Typical: admin-only resources.
const asServer = await fetch(
`${LIFERAY}/o/headless-admin-user/v1.0/user-accounts`,
{ headers: { Authorization: `Bearer ${serverToken}` } },
);
const users = await asServer.json();
// Combine data as needed, then respond to Liferay.
res.json({ status: 'success', users });CX types that follow this flow
CX Composer scaffolds a full working microservice (Node.js or TypeScript) plus the paired OAuth application for each of these types. The route, the container, the OAuth entry — all wired up.