Skip to main content

Identity Software Integration Patterns

This document outlines the standard identity patterns used within the MoJ to ensure services can talk to each other securely while protecting user data.

On behalf of (OBO) Pattern

Use Case

OBO flows are required when an application needs to act as a middleman. It exchanges the user’s initial login token for a new token intended for a different downstream service, all while maintaining the identity and permissions of the logged-in user.

Analogy: Imagine giving a lawyer “Power of Attorney” to sign a house deed for you. The lawyer doesn’t sign as themselves; they sign on your behalf using the specific authority you granted them.

Scenario (Double Hop)

Consider a public-facing web app where users upload documents. This app needs to send those documents to a secure, internal “Central API” for storage.

The Problem: If the Web App simply used its own Client Secret to talk to the Central API, a compromised Web App could upload malicious files as any user. The Central API would have no way of knowing who actually pressed the button.

The Solution: Using OBO, the Web App requests an access token specifically for the Internal API. The Internal API receives this token, validates it, and then exchanges it with Entra ID for a new token addressed to the Central API. This ensures the final service knows exactly which user initiated the request.

In this scenario, if a secret for this application is stolen, a bad actor could cause a large amount of damage by being able to upload files and trigger API calls to the Central API without any authentication checks.

sequenceDiagram
    participant User
    participant Web as 1. Public Web App
    participant Internal as 2. Internal API (Orchestrator)
    participant Central as 3. Centralised MOJ API

    User->>Web: Upload Document
    Note over Web: App is logged in.<br/>Requests NEW Access Token<br/>specifically for Internal API.

    Web->>Internal: Send File + Token A (Aud: Internal API)
    Note over Internal: Validates Token A.<br/>Exchanges Token A for Token B<br/>(Aud: Central API)

    Internal->>Central: Store Metadata (UPN, Object ID)<br/>(with Token B)
    Note right of Central: Extracts User identity<br/>from Token B.<br/>Cannot be spoofed.

    Internal-->>Web: Success
    Web-->>User: Done

Implementation Example

Setup your Applications in Terraform

You will be required to first setup your Application Registrations in Terraform. For this example we will create 3 applications.

Source: OBO Example Terraform Code.

Important Note on Circular Dependencies: When creating these applications from scratch, Terraform may fail if you try to reference the scope_id of the Shared API inside the Internal API’s configuration before the Shared API exists.

Step 1: Create all 3 app registrations in Terraform, but leave the resource_access blocks empty (or commented out). Apply this configuration.

Step 2: Once the Apps and their Scopes are created, update the resource_access block in the Internal API to reference the Shared API.

Step 3: Apply the Terraform again to create the link.

Example Node Implementation

Service A: The Frontend (obo-example-frontend)

The frontend’s job is to request a token specifically for the “Middle Tier”, Service B.

// SERVICE: obo-example-frontend
// Using @azure/msal-node

const msalConfig = {
    auth: {
        clientId: "FRONTEND_CLIENT_ID",
        authority: "https://login.microsoftonline.com/TENANT_ID",
        clientSecret: "FRONTEND_CLIENT_SECRET" // Updated variable name to match feedback
    }
};

const cca = new msal.ConfidentialClientApplication(msalConfig);

// The user logs in. 
// We request a token specifically for the Internal API (Service B).
// This ensures the token's Audience (aud) is set to the Internal API, not the Frontend.
const tokenRequest = {
    scopes: ["api://obo-example-internal-api/user_impersonation"],
    // code: ... (authorization code received from the user's redirect)
};

const result = await cca.acquireTokenByCode(tokenRequest);
const accessTokenForInternalApi = result.accessToken;
// This token can now be sent to the Internal API in the Authorization header.
Service B: The Internal API (obo-example-internal-api)

This is the “Middle Tier”. This app has resource_access to the Shared API. It must perform the exchange.

const express = require('express');
const msal = require('@azure/msal-node');
const app = express();

const cca = new msal.ConfidentialClientApplication({
    auth: {
        clientId: "INTERNAL_API_CLIENT_ID",
        authority: "https://login.microsoftonline.com/TENANT_ID",
        clientSecret: "INTERNAL_API_SECRET"
    }
});

app.get('/process-documents', async (req, res) => {
    // 1. Extract the token sent from the Frontend
    const inboundToken = req.headers.authorization.split(' ')[1];

    // 2. Setup the OBO request for the Shared API
    const oboRequest = {
        oboAssertion: inboundToken,
        // Matches the 'value' in your Shared API terraform
        scopes: ["api://shared-documents/Documents.Read"], 
    };

    try {
        // 3. Exchange Frontend-Token for Shared-API-Token
        const response = await cca.acquireTokenOnBehalfOf(oboRequest);
        const sharedApiToken = response.accessToken;

        // 4. Call the Shared API
        // const data = await axios.get('...', { headers: { Authorization: `Bearer ${sharedApiToken}` } });

        res.send("Successfully chained identity to Shared API!");
    } catch (error) {
        res.status(500).send(error.message);
    }
});
Service C: The Shared API (obo-example-shared-api)

This service doesn’t need to exchange tokens, it just needs to verify them.

const express = require('express');
const passport = require('passport');
const { BearerStrategy } = require('passport-azure-ad');

const options = {
    identityMetadata: "https://login.microsoftonline.com/TENANT_ID/v2.0/.well-known/openid-configuration",
    clientID: "SHARED_API_CLIENT_ID",
    audience: "api://shared-documents", // Matches your identifier_uris
    validateIssuer: true,
    passReqToCallback: false,
    loggingLevel: 'info'
};

const bearerStrategy = new BearerStrategy(options, (token, done) => {
    // Verify the scope exists in the token
    // Note: Delegated flows use 'scp' (scope)
    if (token.scp.includes("Documents.Read")) {
        return done(null, { name: token.name }, token);
    }
    return done(new Error("Missing Documents.Read scope"), null);
});

const app = express();
passport.use(bearerStrategy);

app.get('/docs', passport.authenticate('oauth-bearer', { session: false }), (req, res) => {
    res.json({ docs: ["Policy1.pdf", "Specs.docx"], user: req.user.name });
});

Service to Service (S2S) Pattern

Use Case

Use this for background tasks where no human user is present. This is for “Machine-to-Machine” communication, such as nightly backups, automated logging, or internal file synchronization.

Analogy: This is like a security guard having a master key to the building. They don’t need a specific tenant to let them in; they have their own ID card that identifies them as an authorized employee.

Diagram

sequenceDiagram
    participant Client as Consumer Service
    participant Entra as Entra ID
    participant SDS as SDS API (Resource)

    Note over Client, Entra: Step 1: Authentication
    Client->>Entra: Authenticate (via Managed Identity or Secret)<br/>Request token for SDS

    Note right of Entra: Validates Identity &<br/>Assigned App Roles

    Entra-->>Client: Issue Access Token<br/>(Contains Application Roles)

    Note over Client, SDS: Step 2: Access Resource
    Client->>SDS: Call API with Access Token

    Note left of SDS: Validates Token Signature<br/>Checks for 'Files.Write' Role

    SDS-->>Client: Return Success / Store File

Implementation Example

Setup your Applications in Terraform

You will be required to first setup your Application Registrations in Terraform. For this example we will create 2 applications.

Source: S2S Example Terraform Code.

Important Note on Circular Dependencies: When creating these applications from scratch, Terraform may fail if you try to reference the resource_access before the S2S Example API Resource Application exists.

Step 1: Create both app registrations in Terraform, but leave the resource_access blocks empty (or commented out). Apply this configuration.

Step 2: Once the Apps and their Scopes are created, update the resource_access block in the S2S Example Consumer to reference the S2S Example API Resource.

Step 3: Apply the Terraform again to create the link.

Step 4 (Important): Grant Admin Consent in the Entra Portal. An IDAM Engineer will do this for you.

  • Go to Entra ID > App Registrations > S2S Example Consumer.
  • Select API Permissions.
  • Click Grant admin consent for Ministry of Justice.
  • Without this step, the Consumer will receive a token, but it will lack the required Roles.

Example Node Implementation

Consumer Application (s2s-example-consumer)

The consumer application’s job is to request an authentication token for the resource service.

// SERVICE: s2s-example-client
const msal = require('@azure/msal-node');

const msalConfig = {
    auth: {
        clientId: "CLIENT_APP_ID",
        authority: "https://login.microsoftonline.com/TENANT_ID",
        clientSecret: "CLIENT_SECRET", 
    }
};

const cca = new msal.ConfidentialClientApplication(msalConfig);

async function runBackup() {
    const tokenRequest = {
        // For S2S, the scope is ALWAYS the App ID URI + "/.default"
        // This tells Entra: "Give me all the App Roles I am assigned to"
        scopes: ["api://s2s-example-api/.default"], 
    };

    try {
        const response = await cca.acquireTokenByClientCredential(tokenRequest);

        // Check the Roles
        // If the Terraform Assignment worked, response.accessToken will contain:
        // "roles": ["Files.Write"]
        console.log("Token acquired!", response.accessToken);
    } catch (error) {
        console.error(error);
    }
}
Resource API Application (s2s-example-api-resource)

The API must validate that the token is valid AND that it contains the specific Files.Write role.

// SERVICE: s2s-example-api
const express = require('express');
const passport = require('passport');
const { BearerStrategy } = require('passport-azure-ad');

const options = {
    identityMetadata: "https://login.microsoftonline.com/TENANT_ID/v2.0/.well-known/openid-configuration",
    clientID: "API_CLIENT_ID",
    audience: "api://s2s-example-api", 
    validateIssuer: true,
    passReqToCallback: false,
};

const bearerStrategy = new BearerStrategy(options, (token, done) => {
    // CHECK 1: Is it a valid token? (Handled by library)

    // CHECK 2: Does it have the correct Role?
    // Note: S2S tokens use the 'roles' claim, not 'scp'
    if (token.roles && token.roles.includes("Files.Write")) {
        return done(null, { name: token.appid }, token);
    }
    return done(new Error("Missing 'Files.Write' role"), null);
});

const app = express();
passport.use(bearerStrategy);

app.post('/upload', passport.authenticate('oauth-bearer', { session: false }), (req, res) => {
    res.send("File uploaded successfully by Service: " + req.user.name);
});

Authorization Code Pattern

Use Case

Use this for server-side web applications where a human user logs in and the application needs to perform actions on their behalf. This is for “User-to-Machine” communication, such as a caseworker viewing their assigned cases or a user updating their own profile.

Analogy: This is like giving a valet driver your car keys. You (the User) give the valet (the Web App) a specific key that allows them to drive and park the car (Access the API), but it doesn’t transfer ownership of the car to them.

Diagram

sequenceDiagram
    actor User
    participant Browser
    participant WebApp as Web App (Client)
    participant Entra as Entra ID
    participant API as Backend API (Resource)

    Note over User, Entra: Step 1: Login & Authorization
    User->>Browser: Click "Login"
    Browser->>Entra: Redirect to Sign-In Page<br/>(Requesting 'Case.Create' scope)
    User->>Entra: Enter Credentials & Consent
    Entra-->>Browser: Return Authorization Code
    Browser->>WebApp: Send Code to Callback URL

    Note over WebApp, Entra: Step 2: Token Exchange (Back Channel)
    WebApp->>Entra: Exchange Code + Client Secret
    Entra-->>WebApp: Issue Access Token<br/>(Contains 'scp': 'Case.Create')

    Note over WebApp, API: Step 3: Access Resource
    WebApp->>API: Call API (Authorization: Bearer Token)

    Note left of API: Validates Token Signature<br/>Checks for 'Case.Create' Scope

    API-->>WebApp: Return Success / Created Case ID

Implementation Example

Setup your Applications in Terraform

You will be required to first setup your Application Registrations in Terraform. For this example we will create 2 applications: a Backend API and a Frontend Web Client.

Source: Authorization Code Example Terraform Code.

Important Note on Circular Dependencies: When creating these applications from scratch, Terraform may fail if you try to reference the oauth2_permission_scope_ids before the Backend API Application exists.

Step 1: Create both app registrations in Terraform. Define the oauth2_permission_scope (e.g., Case.Create) in the Backend API, but leave the required_resource_access block in the Frontend Client empty. Apply this configuration.

Step 2: Once the Backend API and its Scopes are created, update the required_resource_access block in the Frontend Web Client to reference the Backend API.

Step 3: Apply the Terraform again to create the link.

Step 4 (Important): Grant Admin Consent in the Entra Portal. An IDAM Engineer will do this for you.

  • Go to Entra ID > App Registrations > Frontend Web Client.
  • Select API Permissions.
  • Click Grant admin consent for Ministry of Justice.
  • Without this step, the Consumer will receive a token, but it will lack the required Roles.

Example Node Implementation

Consumer Application (auth-code-client)

The web application’s job is to redirect the user to sign in, receive the code, and exchange it for a token to call the API.

// SERVICE: auth-code-client (Express App)
const msal = require('@azure/msal-node');
const express = require('express');

const msalConfig = {
    auth: {
        clientId: "CLIENT_APP_ID",
        authority: "https://login.microsoftonline.com/TENANT_ID",
        clientSecret: "CLIENT_SECRET", 
    }
};

const cca = new msal.ConfidentialClientApplication(msalConfig);
const app = express();

// 1. Trigger Login
app.get('/login', async (req, res) => {
    const authCodeUrlParameters = {
        scopes: ["api://backend-api-id/Case.Create", "user.read"],
        redirectUri: "http://localhost:3000/redirect",
    };

    // Get the URL to sign the user in and redirect them
    const response = await cca.getAuthCodeUrl(authCodeUrlParameters);
    res.redirect(response);
});

// 2. Handle Callback
app.get('/redirect', async (req, res) => {
    const tokenRequest = {
        code: req.query.code, // The Authorization Code from Entra
        scopes: ["api://backend-api-id/Case.Create", "user.read"],
        redirectUri: "http://localhost:3000/redirect",
    };

    try {
        const response = await cca.acquireTokenByCode(tokenRequest);

        // Success! We have a token for the user.
        // response.accessToken contains the 'scp' claim.
        console.log("User Token Acquired!");
        res.send("Login Successful. Token: " + response.accessToken);
    } catch (error) {
        console.error(error);
        res.status(500).send(error);
    }
});
Resource API Application (auth-code-api)

The API must validate that the token is valid AND that the user has delegated the specific Case.Create permission (scope) to the app.

// SERVICE: auth-code-api
const express = require('express');
const passport = require('passport');
const { BearerStrategy } = require('passport-azure-ad');

const options = {
    identityMetadata: "https://login.microsoftonline.com/TENANT_ID/v2.0/.well-known/openid-configuration",
    clientID: "API_CLIENT_ID",
    audience: "api://backend-api-id", 
    validateIssuer: true,
    passReqToCallback: false,
};

const bearerStrategy = new BearerStrategy(options, (token, done) => {
    // CHECK 1: Is it a valid token? (Handled by library)

    // CHECK 2: Does it have the correct Scope?
    // Note: Delegated tokens use the 'scp' claim (Space separated string)
    // We check if "Case.Create" is present in that string.

    const scopes = token.scp.split(' ');
    if (scopes.includes("Case.Create")) {
        return done(null, { oid: token.oid, name: token.name }, token);
    }

    return done(new Error("Missing 'Case.Create' scope"), null);
});

const app = express();
passport.use(bearerStrategy);

app.post('/create-case', passport.authenticate('oauth-bearer', { session: false }), (req, res) => {
    // The user identity is now available in req.user
    res.send(`Case created successfully by user: ${req.user.name} (OID: ${req.user.oid})`);
});

Which should you use?

If the action is… Use this Flow Permission Type
A background cron job cleaning up old files. S2S Application Role
A system log upload from a worker node. S2S Application Role
A user viewing their own uploaded documents. OBO Delegated Scope
An admin service acting on a specific user’s file. OBO Delegated Scope

Resources