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
| Field | Value |
|---|---|
| Provider | Microsoft Entra ID (Azure AD) |
| Enterprise App | Stage-IAMSSO-OIDC-SS-CCG-Microsite-Internal-app |
| Tenant ID | db05faca-c82a-4b9d-b9c5-0f64b6755421 |
| Client ID | 1da39382-3759-4268-9ede-a9ebfa019abc |
| Client Type | Public (SPA) — no client secret |
| Redirect URI | https://developers.healthsafepay.com/oauth2/callback/ |
| Authorization Endpoint | https://login.microsoftonline.com/db05faca-c82a-4b9d-b9c5-0f64b6755421/oauth2/v2.0/authorize |
| Token Endpoint | https://login.microsoftonline.com/db05faca-c82a-4b9d-b9c5-0f64b6755421/oauth2/v2.0/token |
| JWK Endpoint | https://login.microsoftonline.com/db05faca-c82a-4b9d-b9c5-0f64b6755421/discovery/v2.0/keys |
| Scopes | openid profile email offline_access api://1da39382-3759-4268-9ede-a9ebfa019abc/openid |
| Required Group | AZU_CCG_INTERNAL_DOCS_Users |
Granting access: Add the user to
AZU_CCG_INTERNAL_DOCS_Usersin Entra ID and ensure the group is assigned to the Enterprise Application under Users and groups.
What to Request from IAM / Platform Teams
-
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
- Register new Enterprise App:
-
Entra ID — Security Group:
- Create group:
AZU_CCG_INTERNAL_DOCS_Users - Assign the group to the Enterprise App under Users and groups
- Create group:
-
No Helm / Istio / nginx changes required — nginx already serves
index.htmlfor all routes viatry_files, which handles the SPA callback route automatically.
Libraries
| Library | Purpose |
|---|---|
@azure/msal-browser | OAuth2 + PKCE engine in the browser |
@azure/msal-react | MsalProvider, AuthenticatedTemplate, useMsal hooks |
Install:
yarn add @azure/msal-browser @azure/msal-react
Auth Flow
- Unauthenticated visit —
Root.jsrenders<UnauthenticatedTemplate>, which callsmsalInstance.loginRedirect(). - Azure AD login — PKCE S256 challenge/verifier handled automatically by
msal-browser. Redirect URI receives the authorization code. - Token exchange —
msal-browserPOSTscode+code_verifierto Azure AD's/tokenendpoint. Returns anid_token(~1h expiry) stored insessionStorage. - Callback handling —
/oauth2/callbackpage callshandleRedirectPromise(), then redirects to/. - Authenticated state —
<AuthenticatedTemplate>renders the full Docusaurus site.
Key Files to Create
| File | Purpose |
|---|---|
src/auth/msalConfig.js | MSAL config — client ID, authority, redirect URI, scopes, isSSOEnabled() flag |
src/auth/msalInstance.js | Shared PublicClientApplication singleton — imported by both Root.js and the callback page |
src/theme/Root.js | Docusaurus root wrapper — MsalProvider + auth gate |
src/pages/oauth2/callback.jsx | Handles 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_UsersreceiveAADSTS50105and cannot complete login. - The
clientIdis 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
| Error | Cause | Fix |
|---|---|---|
AADSTS50105 | User not in AZU_CCG_INTERNAL_DOCS_Users or group not assigned to app | Add user to group; assign group to Enterprise App |
AADSTS53003 | CA policy blocks server-side public client token exchange | Use client-side MSAL — already the current architecture |
AADSTS9002325 | PKCE missing | msal-browser handles PKCE automatically |
| Blank page after login | handleRedirectPromise() not called on callback route | Ensure src/pages/oauth2/callback.jsx exists and calls it |
Difference from test-reports App
test-reports.healthsafepay.com | developers.healthsafepay.com | |
|---|---|---|
| App type | React SPA + Express server | Static Docusaurus site (nginx only) |
| JWT validation | Server-side (jsonwebtoken + JWKS) on /api/* | None — no server |
| Secret storage | Key Vault (TEST-REPORTS-OIDC-AZURE-CLIENT-ID) | Not needed |
| Client ID exposure | Server-side only | Public (SPA bundle) |
| Access enforcement | Server rejects invalid JWTs | Entra ID blocks login at group level |