Building ID Providers

Contents

The XP framework offers a solution for pluggable authentication called ID providers

Introduction

As part of the XP’s Identity and Access Management (IAM) system, services may be loosely associated with an ID provider via vhost configuration.

The ID provider is responsible for providing user authentication, independent of the source identity system.

ID providers are wired tightly into the common HTTP request pipeline. They participate both in pipeline execution steps, and through the contextually mounted ID provider API.

This section describes how to implement an ID provider application.

Two ready-made ID providers are published on Enonic Market and cover most common needs without writing code: OIDC ID Provider — for any OpenID Connect-compliant identity system (Auth0, Okta, Microsoft Entra, Keycloak, etc.) — and Simple ID Provider — a turn-key local identity store with a built-in login form. Reach for those first; implement a custom ID provider only when neither fits.

Endpoints and mounting

Unlike the four services, an ID provider has no URL prefix of its own. Instead, every ID provider is reachable through a single fixed mountpoint:

/_/idprovider/<idprovider-name>

This mountpoint is contextual — the same /_/idprovider/ space resolves within the site, webapp, and admin services (the flat /api endpoint has no /_/ space), so an ID provider can serve its login flow and custom requests regardless of which service the user is interacting with. <idprovider-name> is the name given to the ID provider when it is configured — it is unrelated to the key of the application that implements it.

Three behaviours are reachable under the mountpoint, each invoking the matching implementation function:

ID provider endpoint pipeline
This diagram covers the ID provider’s endpoint surfaces. Its two pipeline hooks — autoLogin (the autologin filter) and handle401 — are triggered by the request pipeline rather than the endpoint, and appear in the common pipeline diagram.
/_/idprovider/<name>/login

Invokes login — a deterministic login endpoint, available for every provider.

/_/idprovider/<name>/logout

Invokes logout.

/_/idprovider/<name>

The base endpoint dispatches to the provider’s GET/POST/… HTTP functions.

/_/idprovider/<name>/device/code, /_/idprovider/<name>/device, /_/idprovider/<name>/token

XPXP8.2.08.2.0 XP’s own device login endpoints, served for the provider’s users when the vhost enables the device flow. The implementation does not serve them; it only renders the verification page, through deviceVerification.

Rather than hardcoding these paths, generate them with the portal library helpers — loginUrl(), logoutUrl() and idProviderUrl() — which resolve the correct contextual URL for the current request.

The endpoint is mounted everywhere, but an ID provider only activates for a request when it is bound to the request’s virtual host. The vhost mapping declares which ID provider(s) apply and, optionally, which flows each of them serves. See the vhost configuration for the binding rules.

Descriptor

An IDprovider application requires a descriptor file. The descriptor should be placed in your project as src/main/resources/idprovider/idprovider.yaml.

Sample idprovider.yaml
kind: "IdProvider"
mode: "LOCAL" (1)
form:         (2)
- type: "TextLine"
  name: "title"
  label: "Title"
  default: "User Login"
  occurrences:
    min: 0
    max: 1
1 mode specifies how users and groups are process and stored in XP. Allowed values are:
  • LOCAL - Users and groups are fully managed by the IDprovider (i.e. no integrations or external ID systems).

  • EXTERNAL - Users and groups exist in a remote system. The IDprovider acts as a "broker" between XP and the remote Identity system.

  • MIXED - Users exist in a remote system, but the groups are created and managed locally in XP.

2 form defines config options that can be defined from the UI when setting up ID providers. Forms are based on the schema system
This forms does not support CMS-specific input types, such as contentSelector and htmlArea.

idprovider.ts

Add an ID provider implementation at src/main/resources/idprovider/idprovider.ts. An application may only implement a single ID provider.

To implement your ID provider, export one or more of the following functions:

XPXP8.2.08.2.0 Every function receives the request. On an ID provider’s own function calls the request also carries request.idProviderSettings (IdProviderSettings): what the virtual host serving the request declares for this ID provider in com.enonic.xp.web.vhost.cfg, read from mapping.<name>.idProvider.<idp>.* and the mapping’s audience. It is absent when the host does not map the ID provider.

{
  "idProviderSettings": {
    "flows": ["device", "login", "logout"],  (1)
    "audience": ["api"],  (2)
    "scopes": {  (3)
      "content:read": {"roles": ["role:cms.project.site.viewer"], "required": false},
      "profile": {"roles": [], "required": true}
    }
  }
}
1 The flows the host serves for the ID provider, sorted. Empty means no restriction. An ID provider uses it to enable its own flows: the standard ID provider serves basic authentication only where basic is listed or the list is empty.
2 The audiences the host serves, sorted. Empty means the host accepts only tokens whose audience is XP’s own issuer name.
3 The scope catalogue (IdProviderScope per id): the roles each scope implies and whether the user must keep it. An empty role list is a label-only scope.
To serve static assets (CSS, JS, images — e.g. for a login page), use the lib-static library, not lib-asset. Like admin extensions, an ID provider runs in a context where lib-asset does not fit; lib-static (typically paired with lib-router) is designed for serving static files from here.

handle401

Perhaps the most common use of ID providers is handling 401 errors (Unauthorized). When a user attempts to access a resource or service that requires authentication, the pipeline will produce an HTTP 401 response. The IDprovider may intercept the response before this is sent to the client (browser) and perform an action that enables the user to authenticate, hopefully giving access to the resource.

login

Clients may request a login directly through a pre-defined endpoint, available for all ID providers. This provides a deterministic endpoint for accessing the login page of any ID provider.

The URL to the endpoint can be generated using the loginUrl() function in the portal library

If the "login" endpoint is called with a "redirect" parameter, a validation of the origin is performed. The result of this validation is then passed to the ID Provider as a request property "validTicket".

logout

Clients may request a logout directly through a pre-defined endpoint, available for all ID providers. This provides a deterministic endpoint for logging out of any ID provider.

The URL for this endpoint can be generated using the logoutUrl() function in the portal library

If the "logout" endpoint is called with a "redirect" parameter, a validation of the origin is performed. The result of this validation is then passed to the ID Provider as a request property "validTicket".

autoLogin

An ID provider may optionally include an AutoLogin filter. The purpose of this filter is to automatically sign in users, before they may access other parts of the execution pipeline. This effectively blocks all unauthorised access attempts for the protected service.

If no user already exists in the context, the autoLogin filter is executed early in the request pipeline.

XPXP8.2.08.2.0 autoLogin runs for every ID provider on the request’s virtual host that has the autologin flow enabled — the default provider first — until one of them authenticates the request or returns a response. In earlier versions only the default provider’s autoLogin ran. Visit the vhost configuration section for more details.

XPXP8.2.08.2.0 A bearer token XP issued itself is accepted before autoLogin runs, on a vhost that enables the autologin flow for the user’s ID provider and accepts the token’s audience (one it declares, or XP’s own issuer name on a vhost declaring none). autoLogin is then not called: the request is already authenticated, and the functions handling it see the user and request.accessToken. Any other bearer token reaches autoLogin untouched. See access tokens.

An ID provider that verifies a bearer token of its upstream identity system can pass the token on with the accessToken option of login(). XP then applies the same rules as to its own tokens: the audience must be one the virtual host declares, and the scopes map to roles through the host’s scope catalogue for the ID provider. The request then carries the token as request.accessToken. Verifying the token stays the ID provider’s job.

import {login} from '/lib/xp/auth';

export function autoLogin(req: Request): void {
    const claims = verifyBearer(req); // the ID provider's own verification against its upstream
    if (!claims) {
        return;
    }
    login({
        user: claims.sub,
        idProvider: 'myIdProvider',
        skipAuth: true,
        scope: 'REQUEST',
        accessToken: {
            issuer: claims.iss,
            clientId: claims.azp ?? claims.client_id,
            scope: ([] as string[]).concat(claims.scp ?? claims.scope ?? []).flatMap((v) => v.split(' ')).filter(Boolean),
            audience: ([] as string[]).concat(claims.aud ?? [])
        }
    });
}

deviceVerification

XPXP8.2.08.2.0 XP serves the OAuth device flow (RFC 8628) for an ID provider’s users: a command-line tool or another browserless client asks XP for a code, the user opens a page, signs in and approves, and the client receives XP-issued tokens. XP owns the protocol, the codes and the tokens; the page the user sees is the ID provider’s, as the login page is. An ID provider that exports deviceVerification supports device login; one that does not answers 404 on the device endpoints, so a client is never handed a code it cannot complete.

deviceVerification(req) is called for GET and POST on /_/idprovider/<name>/device, once XP has done its part: the user is signed in (an unauthenticated request is answered with 401 and reaches the page again after the interactive login), the code has been looked up, and a posted choice has been applied. The function renders a page for the state XP hands it in req.deviceLogin (DeviceLogin, a union narrowed by action):

Name Type Description

idProvider

string

Name of the ID provider the page belongs to.

action

string

What to show: enter (ask for the code the client displays), confirm (show the request and ask for approval), done (the request was approved or denied) or wrongAccount (the signed-in user belongs to another ID provider and cannot approve here).

user

DeviceLoginUser

The signed-in user: key, login, displayName.

error

string

Optional. On enter: why the previous code was refused. The only value so far is invalid_code; the page supplies the wording.

userCode

string

On confirm: the code the client displays, so the user can check it matches.

clientId

string

On confirm: the name the client gave itself. XP has no client registry yet, so it is a label, not a verified identity.

scope

DeviceLoginScope[]

On confirm: the scopes the client asked for, each as id, the roles it implies and whether it is required. Empty when the client asked for none.

unrestricted

boolean

On confirm: true when the vhost has no scope catalogue, so an approved token carries the user’s full memberships.

audience

string[]

On confirm: the audiences the token will be for.

csrf

string

On confirm: a per-session token the form must post back as _csrf.

approved

boolean

On done: whether the user approved.

The confirm page is a form that posts back to the same URL: user_code, approve (true or false), _csrf, and one scope field per scope the user keeps. A scope left out is not granted, except a required one, which is granted whenever the request is approved; a scope that was never asked for is ignored. XP validates the posted choices; the page only renders. XP hands the page scope ids and roles, not descriptions, so the page supplies the wording, typically by looking the id up in its own texts and falling back to the id.

Minimal deviceVerification
import type {Request, Response} from '@enonic-types/core';

const escape = (value: string): string =>
    value.replace(/[&<>"']/g, (c) => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'})[c] as string);

export function deviceVerification(req: Request): Response {
    const d = req.deviceLogin;
    if (!d) {
        return {status: 404};
    }
    if (d.action === 'confirm') {
        const scopes = d.scope
            .map(s => `<label><input type="checkbox" name="scope" value="${escape(s.id)}" checked ${s.required ? 'disabled' : ''}> ${escape(s.id)}</label>`)
            .join('');
        const required = d.scope
            .filter(s => s.required)
            .map(s => `<input type="hidden" name="scope" value="${escape(s.id)}">`)
            .join('');
        return {
            contentType: 'text/html',
            body: `<form method="post">
                <h1>Authorize ${escape(d.clientId)}</h1>
                <p>Code: ${d.userCode}</p>
                ${scopes}${required}
                <input type="hidden" name="user_code" value="${d.userCode}">
                <input type="hidden" name="_csrf" value="${d.csrf}">
                <button name="approve" value="true">Authorize</button>
                <button name="approve" value="false">Cancel</button>
            </form>`,
        };
    }
    if (d.action === 'done') {
        return {contentType: 'text/html', body: d.approved ? '<p>Request approved. You may close this window.</p>' : '<p>Request denied.</p>'};
    }
    if (d.action === 'wrongAccount') {
        return {contentType: 'text/html', body: `<p>${escape(d.user.displayName)} cannot approve requests for ${escape(d.idProvider)}.</p>`};
    }
    return {
        contentType: 'text/html',
        body: `<form method="get">${d.error ? '<p>That code was not recognised.</p>' : ''}
            <input name="user_code" placeholder="Enter code">
            <button>Next</button>
        </form>`,
    };
}
clientId is whatever the client sent, and a display name is whatever the user chose, so the example escapes them, and scope ids with them. A template engine that escapes by default does the same.

GET/POST etc

Additionally, an ID provider may also act as a regular HTTP function — supporting any interaction as desired. Communication with the implementation is handled via the ID provider endpoint.

The URL to this endpoint can be generated using the idProviderUrl() function in the portal library

Flows

XPXP8.2.08.2.0 A vhost mapping may restrict the flows an ID provider serves, with the provider’s flows setting — e.g. idProvider.myidp.flows = login,autologin. XP itself enforces the three flows it manages:

login

handle401 and the login function.

autologin

the autoLogin filter.

logout

the logout function.

Any other name in the list addresses an additional flow of the ID provider application itself. XP dispatches the custom GET/POST functions regardless of the list, and instead hands the list to the implementation as request.idProviderSettings.flows, empty when no restriction applies. The property is absent when the vhost applies no flow restriction (and on XP versions before 8.2.0), so an absent list means every flow is enabled.

Use it to serve an endpoint only where its flow is listed. For example, an ID provider offering a device authorization flow can gate it on the name device:

import type {Request, Response} from '@enonic-types/core';

export function POST(request: Request): Response {
    const flows = request.idProviderSettings?.flows ?? [];
    if (flows.length > 0 && !flows.includes('device')) {
        return {status: 403};
    }
    // serve the device authorization endpoint
}

An administrator then enables the endpoint per vhost by listing the flow — flows = autologin,device — and disables it by leaving it out. Document the additional flows your ID provider supports, so administrators know what the names mean.

Sample

The code below demonstrates how an ID provider may be implemented

Sample idprovider.ts
import {logout as logoutUser} from '/lib/xp/auth';

// Filter every request
export function autoLogin(req) {
    log.info('Invoked only unless user is already authenticated');
}

// Override error handler when authentication is required
export function handle401(req) {
    const body = generateLoginPage();
    return {
        status: 401,
        contentType: 'text/html',
        body: body
    };
}

// Triggered when user visits the ID providers login endpoint
export function login(req) {

    const redirectUrl = req.validTicket ? req.params.redirect : undefined;

    const body = generateLoginPage(redirectUrl);
    return {
        contentType: 'text/html',
        body: body
    };
}

// Triggered when user visits the ID providers logout endpoint
export function logout(req) {

    // Sign user out of XP
    logoutUser();

    const redirectUrl = req.validTicket ? req.params.redirect : undefined;

    if (redirectUrl) {
        return {
            redirect: redirectUrl
        };
    } else {
        const body = generateLoginPage();
        return {
            contentType: 'text/html',
            body: body
        };
    }
}

Contents

Contents