Checking access…

Skip to main content
Version: v2

SSO Integration — developers.healthsafepay.com

Overview

Azure AD SSO using the SPA (client-side) pattern:

  • OAuth 2.0 Authorization Code + PKCE (S256) flow runs entirely in the browser via @azure/msal-browser / @azure/msal-react.
  • This is a static Docusaurus site (no server process) — auth is enforced client-side only.
  • Entra ID blocks login at the identity layer for users not in the required security group.
  • A separate OIDC client and security group are created exclusively for this site.

Identity Provider Registration

FieldValue
ProviderMicrosoft Entra ID (Azure AD)
Enterprise AppStage-IAMSSO-OIDC-SS-CCG-Microsite-Internal-app
Tenant IDdb05faca-c82a-4b9d-b9c5-0f64b6755421
Client ID1da39382-3759-4268-9ede-a9ebfa019abc
Client TypePublic (SPA) — no client secret
Redirect URIhttps://developers.healthsafepay.com/oauth2/callback/
Authorization Endpointhttps://login.microsoftonline.com/db05faca-c82a-4b9d-b9c5-0f64b6755421/oauth2/v2.0/authorize
Token Endpointhttps://login.microsoftonline.com/db05faca-c82a-4b9d-b9c5-0f64b6755421/oauth2/v2.0/token
JWK Endpointhttps://login.microsoftonline.com/db05faca-c82a-4b9d-b9c5-0f64b6755421/discovery/v2.0/keys
Scopesopenid profile email offline_access api://1da39382-3759-4268-9ede-a9ebfa019abc/openid
Required GroupAZU_CCG_INTERNAL_DOCS_Users

Granting access: Add the user to AZU_CCG_INTERNAL_DOCS_Users in Entra ID and ensure the group is assigned to the Enterprise Application under Users and groups.


What to Request from IAM / Platform Teams

  1. Entra ID — App Registration:

    • Register new Enterprise App: Stage-IAMSSO-OIDC-SS-CCG-Microsite-Internal-app
    • Type: SPA (Public client)
    • Redirect URI: https://developers.healthsafepay.com/oauth2/callback
    • Scopes: openid profile email
  2. Entra ID — Security Group:

    • Create group: AZU_CCG_INTERNAL_DOCS_Users
    • Assign the group to the Enterprise App under Users and groups
  3. No Helm / Istio / nginx changes required — nginx already serves index.html for all routes via try_files, which handles the SPA callback route automatically.


Libraries

LibraryPurpose
@azure/msal-browserOAuth2 + PKCE engine in the browser
@azure/msal-reactMsalProvider, AuthenticatedTemplate, useMsal hooks

Install:

yarn add @azure/msal-browser @azure/msal-react

Auth Flow

  1. Unauthenticated visitRoot.js renders <UnauthenticatedTemplate>, which calls msalInstance.loginRedirect().
  2. Azure AD login — PKCE S256 challenge/verifier handled automatically by msal-browser. Redirect URI receives the authorization code.
  3. Token exchangemsal-browser POSTs code + code_verifier to Azure AD's /token endpoint. Returns an id_token (~1h expiry) stored in sessionStorage.
  4. Callback handling/oauth2/callback page calls handleRedirectPromise(), then redirects to /.
  5. Authenticated state<AuthenticatedTemplate> renders the full Docusaurus site.

Key Files to Create

FilePurpose
src/auth/msalConfig.jsMSAL config — client ID, authority, redirect URI, scopes, isSSOEnabled() flag
src/auth/msalInstance.jsShared PublicClientApplication singleton — imported by both Root.js and the callback page
src/theme/Root.jsDocusaurus root wrapper — MsalProvider + auth gate
src/pages/oauth2/callback.jsxHandles the post-login redirect from Entra ID

Implementation

src/auth/msalConfig.js

const CLIENT_ID = '1da39382-3759-4268-9ede-a9ebfa019abc';
const TENANT_ID = 'db05faca-c82a-4b9d-b9c5-0f64b6755421';

export const msalConfig = {
auth: {
clientId: CLIENT_ID,
authority: `https://login.microsoftonline.com/${TENANT_ID}`,
redirectUri:
typeof window !== 'undefined'
? window.location.origin + '/oauth2/callback/'
: 'https://developers.healthsafepay.com/oauth2/callback/',
postLogoutRedirectUri: '/',
},
cache: {
cacheLocation: 'sessionStorage',
storeAuthStateInCookie: true, // required for Safari/ITP redirect flows
},
};

export const loginRequest = {
scopes: ['openid', 'profile', 'email', 'offline_access', `api://${CLIENT_ID}/openid`],
};

// SSO_ENABLED = false suppresses the gate everywhere (site stays public).
// Flip to true once the Entra ID security group is confirmed and ready.
const SSO_ENABLED = false;

export function isSSOEnabled() {
if (!SSO_ENABLED) return false;
if (typeof window === 'undefined') return false;
return window.location.hostname === 'developers.healthsafepay.com';
}

src/auth/msalInstance.js

import { PublicClientApplication, EventType } from '@azure/msal-browser';
import { msalConfig, isSSOEnabled } from './msalConfig';

let msalInstance = null;
if (isSSOEnabled()) {
msalInstance = new PublicClientApplication(msalConfig);
msalInstance.addEventCallback(event => {
if (event.eventType === EventType.LOGIN_SUCCESS && event.payload?.account) {
msalInstance.setActiveAccount(event.payload.account);
}
});
}
export { msalInstance };

src/theme/Root.js

Docusaurus supports swizzling the Root component to wrap the entire app — this is where MsalProvider and the auth gate live.

import { InteractionStatus } from '@azure/msal-browser';
import { MsalProvider, AuthenticatedTemplate, UnauthenticatedTemplate, useMsal } from '@azure/msal-react';
import { loginRequest } from '../auth/msalConfig';
import { msalInstance } from '../auth/msalInstance';

function LoginPage() {
const { instance, inProgress } = useMsal();
const redirectStarted = useRef(false);
useEffect(() => {
if (inProgress !== InteractionStatus.None) return;
if (redirectStarted.current) return;
redirectStarted.current = true;
instance.loginRedirect(loginRequest).catch(err => {
redirectStarted.current = false; // reset so next render can retry
console.error('[SSO] loginRedirect failed:', err);
});
}, [instance, inProgress]);
return <p>Redirecting to Enterprise SSO login…</p>;
}

src/pages/oauth2/callback.jsx

import { isSSOEnabled } from '../../auth/msalConfig';
import { msalInstance } from '../../auth/msalInstance';

export default function OAuthCallback() {
useEffect(() => {
if (!isSSOEnabled() || !msalInstance) {
window.location.replace('/');
return;
}
msalInstance.handleRedirectPromise()
.then(() => window.location.replace('/'))
.catch(err => setError(err?.message || 'Authentication failed.'));
}, []);
<p style={{ padding: '2rem' }}>Signing in, please wait…</p>
</Layout>
);
}

Security Model

Because this is a static site with no backend, there is no server-side JWT validation. The security boundary is:

  • Entra ID enforces group membership — users not assigned to AZU_CCG_INTERNAL_DOCS_Users receive AADSTS50105 and cannot complete login.
  • The clientId is intentionally public (baked into the SPA bundle) — this is the standard SPA pattern; no secret is involved.
  • Content is not inherently secret at the network layer (static files are served by nginx). The auth gate is a UX/access-control layer appropriate for internal documentation sites.

Common Errors

ErrorCauseFix
AADSTS50105User not in AZU_CCG_INTERNAL_DOCS_Users or group not assigned to appAdd user to group; assign group to Enterprise App
AADSTS53003CA policy blocks server-side public client token exchangeUse client-side MSAL — already the current architecture
AADSTS9002325PKCE missingmsal-browser handles PKCE automatically
Blank page after loginhandleRedirectPromise() not called on callback routeEnsure src/pages/oauth2/callback.jsx exists and calls it

Difference from test-reports App

test-reports.healthsafepay.comdevelopers.healthsafepay.com
App typeReact SPA + Express serverStatic Docusaurus site (nginx only)
JWT validationServer-side (jsonwebtoken + JWKS) on /api/*None — no server
Secret storageKey Vault (TEST-REPORTS-OIDC-AZURE-CLIENT-ID)Not needed
Client ID exposureServer-side onlyPublic (SPA bundle)
Access enforcementServer rejects invalid JWTsEntra ID blocks login at group level