Building a browser single-page app with oidc-caep-client
This guide shows how to build a browser single-page app (SPA) on oidc-caep-client. By the end, your app signs people in with OpenID Connect, keeps their tokens fresh, calls your APIs with sender-constrained tokens, and reacts within seconds when the identity provider reports a change about a signed-in user through CAEP.
Everything runs in the browser. Your web server only serves files and security headers; it holds no tokens and no secrets. The library is a public client: it never has a client secret, so anyone can read your page’s code without gaining anything.
The README is the API reference: every option, method and event. This guide is the walkthrough. It explains which pieces to use, in what order, and why. The demo in this repository (demo/) is a working app built this way.
Who this is for. Apps whose code runs in a web page: React, Vue, Svelte, or plain ES modules. It isn’t for native mobile apps, and it isn’t for server-rendered apps that can keep a secret. Those should use a confidential client on the server.
Contents
- How the pieces fit together
- Set up the identity provider
- Serve the app
- Create the client
- Choose where tokens and keys live
- Sign in
- Call your APIs
- Several accounts in one browser
- Receive CAEP events
- Sign out
- Errors
- Security checklist
- Troubleshooting
1. How the pieces fit together
| Piece | What it does |
|---|---|
OIDCClient |
Sign-in (Authorization Code + PKCE, PAR, DPoP), ID token validation, refresh, UserInfo, sign-out. |
TokenManager |
Caches one person’s tokens and refreshes them before they expire. |
AccountManager |
Several people signed in to one browser, one active account per tab. Use it instead of TokenManager when you need that. |
SSFReceiver |
Subscribes to the identity provider’s Shared Signals stream, polls it, validates each event and hands it to your handlers. |
| Stores | Where tokens, the DPoP key and the in-flight sign-in live: memory, sessionStorage or IndexedDB. |
Browser (your SPA) Identity provider
─────────────────── ─────────────────
OIDCClient ──── authorize (PAR), token, UserInfo ────▶ OpenID Connect endpoints
TokenManager / AccountManager (refresh, DPoP proofs)
SSFReceiver ──── create / read stream, poll ─────────▶ SSF transmitter (/ssf/...)
▲ │
└──────────── signed security events (SETs) ◀───────────┘
your handlers: end the session, refresh tokens, ask for step-up, ...
your APIs ◀──── fetchResource(): access token + DPoP proof
The identity provider is usually both the OpenID provider and the SSF transmitter. The receiver authenticates to the transmitter with the signed-in person’s own access token, so it can only see that person’s stream. A browser can’t receive pushed events, so delivery is always poll (RFC 8936).
2. Set up the identity provider
Register your app as a public client:
| Setting | Value |
|---|---|
| Client type | public, token_endpoint_auth_method: none |
| Grant types | authorization_code, refresh_token |
| Response type | code |
| Redirect URI | your callback page, e.g. https://app.example.com/callback |
| Post-logout redirect URI | e.g. https://app.example.com/ |
| Allowed scopes | openid profile email, offline_access for refresh tokens, and the CAEP scopes below |
| CORS origin | your app’s origin, e.g. https://app.example.com |
CORS is required. The browser calls the identity provider directly from your origin: discovery, the PAR and token endpoints, UserInfo, revocation, and every SSF endpoint. If the provider doesn’t allow your origin, these calls fail with a network error.
TLS. Every provider endpoint, and your redirect URIs, must be https. Loopback http (localhost, 127.0.0.1) is allowed for development. The browser must also trust the provider’s certificate. With a self-signed development certificate, open the provider’s URL once and accept it, or install a local CA such as mkcert.
CAEP scopes. These are the CAEP Interoperability Profile’s names:
| Scope | Lets the receiver |
|---|---|
ssf.read |
read its stream and its status, and poll for events |
ssf.manage |
everything ssf.read does, plus create, change or delete a stream, add or remove subjects, change the stream’s status and request a verification event |
An app that only consumes a stream someone else created needs ssf.read. An app that creates its own stream, chooses its event types or adds the user as the stream’s subject needs ssf.manage.
iya-sts
With iya-sts, both scopes are gated by role. A person is issued ssf.read only while two things are true: they are in the group ssf-read, and your application is an application member of the native role SSF_READ. ssf.manage works the same way through the group ssf-manage and the role SSF_MANAGE, which does not authorize ssf.read, so ask for both. Every realm has these roles and groups by default. Applications have to be added to the roles; nothing adds them for you.
scripts/register-iya-sts-client.mjs does all of this for the demo:
- registers the public client;
- adds it to
SSF_READandSSF_MANAGE; - creates a test user in
ssf-readandssf-manage.
Read it as an example of the management API calls involved.
Two settings look similar but don’t grant a scope:
- An application’s required roles (
appRequiredRole) decide who may sign in to it. - A role’s conferring clients lend the role to people who sign in through them. The SSF roles ignore conferral.
3. Serve the app
Install the library
npm install oidc-caep-client
That adds "oidc-caep-client": "^0.2.0" to the dependencies in your package.json, and installs its one dependency, jose. Versions are ordinary semver, so ^0.2.0 takes every later 0.2 release; pin one version instead for reproducible builds. npm install oidc-caep-client@next gets the latest pre-release built from develop, ahead of a release. The README’s Installing section has the details.
TypeScript. The package ships its own declarations, so a TypeScript app imports it the same way and gets the types, e.g. type SecurityEvent for an event handler’s parameter. Set "module": "NodeNext" or your bundler’s resolution so TypeScript reads the package’s exports. The examples below are JavaScript; they compile as TypeScript unchanged.
Load the library
The library is plain ES modules. With a bundler, import { OIDCClient } from 'oidc-caep-client' just works. Without one, serve both packages from node_modules and add an import map, as the demo does:
<script type="importmap">{"imports":{"jose":"/vendor/jose/index.js","oidc-caep-client":"/lib/index.js"}}</script>
<script type="module" src="/app.js"></script>
Serve node_modules/oidc-caep-client/src/ at /lib/, and jose’s browser build (node_modules/jose/dist/webapi/) at /vendor/jose/.
Serve the page at the callback path too
The provider sends the browser back to your redirect URI (/callback) with code and state in the query string. Serve your app’s page there as well, so the same code completes the sign-in.
Send these headers
The authorization code arrives in the page’s URL, and the page holds live tokens. Lock it down:
Content-Security-Policy: default-src 'none'; script-src 'self' 'sha256-<hash of the import map>'; style-src 'self'; img-src 'self' data:; connect-src 'self' https:; base-uri 'none'; form-action 'self'; frame-ancestors 'none'; object-src 'none'
Referrer-Policy: no-referrer
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Cross-Origin-Opener-Policy: same-origin
Cache-Control: no-store (on the HTML page)
- No inline script. An injected script can use your tokens while the page is open. CSP is what keeps it out.
Referrer-Policy: no-referrerkeeps the code and state in the callback URL out ofRefererheaders (RFC 9700 §4.2.4).frame-ancestors 'none'stops another site from framing your sign-in button (RFC 9700 §4.16).- Narrow
connect-srcto your provider’s and your APIs’ origins once you know them.
demo/server.js is a complete example, including how it computes the import map’s hash.
4. Create the client
import { OIDCClient } from 'oidc-caep-client';
const client = await OIDCClient.discover({
issuer: 'https://idp.example.com', // reads /.well-known/openid-configuration
clientId: 'my-spa',
redirectUri: `${location.origin}/callback`,
postLogoutRedirectUri: `${location.origin}/`,
scope: 'openid profile email offline_access ssf.read ssf.manage',
dpop: true, // see section 5 for where the key lives
});
If you already have the metadata document, use OIDCClient.fromMetadata(metadata, { ... }) instead. The client checks the metadata before using it:
- PKCE: the provider must offer
S256, otherwise the client refuses it. - Public clients: the provider must accept
noneas a token endpoint authentication method. - TLS: every endpoint must be
https, apart from loopback addresses.
PAR is used whenever the provider offers it (par: 'auto'). client.securityFeatures lists the protections in effect, which is useful to show on a diagnostics page.
5. Choose where tokens and keys live
Anything in browser storage can be read by script running in your page. Two things limit what that script can do with stolen tokens:
- DPoP binds each token to a key pair. A token is useless without a proof signed by the private key.
- A non-extractable WebCrypto key can sign while the page is open, but can’t be read out. So a stolen token can’t be used anywhere else.
Pick one row:
| Setup | Tokens | DPoP key | Survives | Trade-off |
|---|---|---|---|---|
| Memory (default) | MemoryTokenStore |
MemoryKeyStore |
nothing; a reload signs the person in again (often silently) | Smallest exposure; most sign-in round trips. |
| Per tab | WebStorageTokenStore (sessionStorage) |
WebStorageKeyStore (sessionStorage) |
reloads and the trip to the provider; cleared when the tab closes | Each tab has its own sign-in. The key is extractable, because Web Storage holds only strings. |
| Whole browser | IndexedDBTokenStore |
IndexedDBKeyStore |
closing the browser | Every tab shares the accounts. The key stays non-extractable. Tokens last until sign-out. |
import { IndexedDBKeyStore, OIDCClient, WebStorageKeyStore } from 'oidc-caep-client';
// Whole browser: a non-extractable key that survives reloads.
const client = await OIDCClient.discover({ /* ... */ dpop: { keyStore: new IndexedDBKeyStore() } });
// Per tab: the demo's choice.
const perTab = await OIDCClient.discover({ /* ... */ dpop: { keyStore: new WebStorageKeyStore() } });
AccountManager defaults to the whole-browser row only when DPoP is on, and to memory otherwise. Pass a persistent store without DPoP and it logs a warning: tokens readable by script and usable anywhere shouldn’t outlive the page.
The in-flight sign-in (state, nonce, the PKCE verifier) always goes to sessionStorage. It’s single use and expires after 10 minutes (transactionTtlSec).
6. Sign in
For one person per browser, TokenManager is enough:
import { TokenManager } from 'oidc-caep-client';
const tokens = new TokenManager(client); // memory by default; pass { store } for another
// On the sign-in button:
async function signIn() {
const { url } = await client.createAuthorizationRequest();
location.assign(url);
}
// On page load:
if (location.pathname === '/callback') {
try {
const tokenSet = await client.callback(location.href);
await tokens.set('user', tokenSet); // refreshed in the background from now on
} finally {
history.replaceState(null, '', '/'); // take the code out of the address bar
}
}
callback() checks everything before returning tokens:
- The response:
state(single use, bound to this provider and client), theissparameter (RFC 9207), and that no tokens or repeated parameters arrived in the URL. - The tokens it gets back: the ID token’s signature and claims,
nonceandat_hash.
If any check fails, it throws (section 11). Then send the person back to signIn().
Useful options on createAuthorizationRequest():
| Option | Use |
|---|---|
prompt: 'login' |
Ask for the password again (a fresh sign-in). |
prompt: 'none' |
Try silently, e.g. after a reload with memory storage. Fails with login_required if there is no session. |
loginHint, idTokenHint |
Name the person you expect. |
expectedSub |
Refuse a sign-in as anybody else (account_mismatch). |
acrValues, maxAge |
Step-up: ask for a stronger or more recent authentication. With enforceAcr (the default), a returned acr outside acrValues is refused. |
context |
Your own JSON data, handed back by completeAuthorization(). Use it, for example, to return to the page the person started from. |
extraParams adds other parameters, such as claims or ui_locales. It can’t replace the ones the client sets itself (response_type, redirect_uri, state, nonce, the PKCE and DPoP parameters, resource): those throw a TypeError.
7. Call your APIs
const tokenSet = await tokens.getTokenSet('user'); // refreshed first if due
const res = await client.fetchResource(tokenSet, 'https://api.example.com/orders', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(order),
});
fetchResource() sends the access token. When the token is DPoP-bound, it also sends a fresh proof for this method and URL. If your API asks for a DPoP nonce, it retries with that nonce. With your own HTTP client, get the headers from client.resourceHeaders(tokenSet, { method, url }) and send them yourself.
Your API must then verify the proof as RFC 9449 says: htm, htu, ath and jti, and that the key matches the token’s cnf.jkt.
8. Several accounts in one browser
Some providers, iya-sts among them, keep several people signed in to one browser. AccountManager handles the app’s side of that:
- every account’s tokens are cached;
- each tab has one active account;
- the others are dormant: kept, but neither refreshed nor used.
import { AccountManager, WebStorageTokenStore } from 'oidc-caep-client';
const accounts = await new AccountManager(client, {
store: new WebStorageTokenStore(), // or the default IndexedDBTokenStore for the whole browser
onActivate: async (account, { getTokenSet }) => { /* start this account's CAEP receiver */ },
onDeactivate: (account) => { /* stop polling its stream */ },
onLeave: async (account, { getTokenSet }) => { /* signed out or ended: delete its stream */ },
}).init();
location.assign((await accounts.signIn()).url); // or addAccount() for another person
const { account } = await accounts.handleCallback(location.href);
const tokenSet = await accounts.getTokenSet(); // always the active account's
- Switching accounts.
accounts.activate(key)makes another account active. It refreshes that account’s tokens first, and lets its receiver catch up on events queued while it was dormant. - Signing a signed-out account back in.
reauthenticate(key)starts a sign-in that names that account (login_hint,prompt=login) and refuses anybody else. - Events.
on('signed-out' | 'ended' | 'activated' | ...)tells you when to update the page.
The README’s Several accounts in one browser section has every rule.
9. Receive CAEP events
Start a receiver for the signed-in person
import { SSFReceiver, Subject, subjectMatches } from 'oidc-caep-client';
const receiver = new SSFReceiver({
transmitterIssuer: 'https://idp.example.com', // reads /.well-known/ssf-configuration
// Every call to the transmitter carries this person's token, with a DPoP proof when bound.
authorizationHeaders: async (req) => client.resourceHeaders(await tokens.getTokenSet('user'), req),
description: `my-spa @ ${location.origin}`,
pollIntervalMs: 5000,
});
await receiver.start({ catchUp: true }); // discover, create or reuse the stream, act on what's queued
await receiver.addSubject(Subject.issSub(user.iss, user.sub)).catch(() => {}); // needs ssf.manage
start() either opens an existing stream or creates one:
- Opening an existing stream: pass its id as
streamId. Keep the id, for example inlocalStorage, to reopen the same stream later. - Creating a stream: this needs
ssf.manage. If the transmitter refuses a new one because this person already has a stream (409) or as many as they may hold (403 — iya-sts’s answer pastssf.maxStreams), an existing one is reused: the one whosedescriptionis this receiver’s, or else the first.
catchUp: true handles the events queued while nobody was polling, before start() returns. Do this before the person uses the app, so a session revoked in the meantime is acted on first.
With ssf.read only, you can’t create a stream. Find one with listStreams() and pass its id as streamId. The demo shows both paths (startCaep() in demo/public/app.js).
Choose the event types
A new stream asks for every CAEP event type unless you pass eventsRequested. To change an existing stream’s types (needs ssf.manage):
import { CAEP } from 'oidc-caep-client';
await receiver.updateStream({ events_requested: [CAEP.SESSION_REVOKED, CAEP.CREDENTIAL_CHANGE, CAEP.RISK_LEVEL_CHANGE] });
console.log(receiver.stream.events_delivered); // what the transmitter will actually send
The transmitter decides events_delivered: the types you asked for that it supports. The scopes control which operations you may perform on the stream, not which event types you may receive. The demo’s CAEP tab lets the person tick types and applies them with this call.
Handle the events
Register one handler per event type. Each handler gets a validated event: type, name, payload, subject, reasonUser, reasonAdmin, jti and more.
receiver
.onSessionRevoked(async () => signOutLocally('Your session was ended by the identity provider.'))
.onTokenClaimsChange(async () => tokens.refresh('user'))
.onCredentialChange(async (evt) => {
if (['revoke', 'delete'].includes(evt.payload.change_type)) await signOutLocally('Sign in again.');
})
.onAssuranceLevelChange((evt) => {
if (evt.payload.change_direction === 'decrease') askForStepUp(); // createAuthorizationRequest({ acrValues, prompt: 'login' })
})
.onDeviceComplianceChange(async (evt) => {
if (evt.payload.current_status === 'not-compliant') await signOutLocally('This device no longer meets policy.');
})
.onRiskLevelChange(async (evt) => {
if (evt.payload.current_level === 'HIGH') await signOutLocally('Unusual activity was detected.');
})
.onSessionEstablished(() => {}) // informational
.onSessionPresented(() => {}) // informational
.onError((err, ctx) => console.warn('CAEP', ctx.phase, err));
Which events need action is your policy. These are reasonable defaults:
| Event | Reasonable reaction |
|---|---|
| session-revoked | End the local session and discard its tokens. |
| token-claims-change | Refresh tokens so the new claims (payload.claims) take effect. |
| credential-change | Revoked or deleted: end the session. Otherwise tell the person. |
| assurance-level-change | Decrease: require step-up before sensitive actions. |
| device-compliance-change | Not compliant: end the session, or limit what the app allows. |
| risk-level-change | HIGH: end the session. MEDIUM: require step-up. |
| session-established, session-presented | Informational; record or display them. |
Check who an event is about. A stream can carry events about other sessions of the same person. Before acting, compare the event’s subject with the session you hold:
if (!subjectMatches(evt.subject, { iss: user.iss, sub: user.sub, sid: idTokenClaims.sid })) return;
An email or phone number subject counts only with email_verified / phone_number_verified: true in the identity: an unverified address could belong to anybody. An event that names a session matches only the identity holding that sid.
Add your own logic for every event
onEventReceived runs once for every accepted event, before the typed handlers. By default it prints the event’s name and jti to the console (logEventReceived), leaving out the payload and subject, which can carry personal data. Put logging, auditing or analytics here:
import { logEventReceived } from 'oidc-caep-client';
const receiver = new SSFReceiver({
/* ... */
onEventReceived: async (evt) => {
logEventReceived(evt); // keep the console line, or drop it
await myAudit.record({ event: evt.name, subject: evt.subject, at: evt.eventTimestamp });
},
});
Pass onEventReceived: null to turn it off. If it throws, onError gets the error with phase: 'hook', and the typed handlers still run.
What the receiver checks for you
Every event (a signed SET) is refused unless all of these hold:
- The signature verifies against the transmitter’s keys, uses an asymmetric algorithm, and has
typ: secevent+jwt. issis the transmitter.audis your stream’s.iatisn’t in the future. There’s no age limit by default, so events queued while nobody was polling are still acted on when the receiver catches up.eventsholds exactly one event, and a stream event (verification, stream-updated) names this stream.jtihasn’t been seen before. A repeat is dropped.
The receiver is careful about what it tells the transmitter, because an acknowledged event, or one reported as an error, is never sent again:
- An invalid event goes to
onError, is reported back to the transmitter on the next poll, and never reaches your handlers. - An event that couldn’t be checked for a reason that may pass (a network error fetching the keys, a key the cached set doesn’t hold yet) is left for the transmitter to send again, for as long as that lasts.
- Polls never overlap, so an event still being handled is never acknowledged by another poll.
- An event is acknowledged only after your handlers succeed. If one throws, the event comes back on a later poll, up to
maxDeliveryAttemptstimes (default 5). Write handlers so that running twice is harmless; a reload can also bring an event back. - The poll URL must use https and be at the transmitter’s origin, because every poll carries the person’s access token.
Without an onError, errors are written to console.error, so a lost event always leaves a trace.
Stop and clean up
receiver.stop(); // stop polling; the stream keeps queueing events
await receiver.stop({ deleteStream: true }); // on sign-out, with ssf.manage: remove the stream
What happens to the stream in each case:
| When | What happens | What to do |
|---|---|---|
| The person signs out | Nothing, unless you delete it. | stop({ deleteStream: true }) with ssf.manage (with AccountManager, from onLeave). With ssf.read only you can’t delete it: stop polling. |
| The provider or a security event ends the session | The tokens are usually revoked already, so a delete is refused. | Stop polling; the transmitter times the stream out. |
| The tab is closed | Polling stops; the stream stays and keeps queueing events. | Keep the stream id (for example in localStorage) and pass it as streamId next time, with catchUp: true, so the events that arrived meanwhile are handled first. |
| The id is lost (another browser, cleared storage) | A new stream is created, and the old one is abandoned. | Nothing in the page: the transmitter removes an abandoned stream. iya-sts deletes a person’s poll stream that nobody has polled for ssf.personStreamInactivityTimeoutS (a day by default). At the transmitter’s limit, start() reuses an existing stream rather than failing. |
deleteOnPageHide: true deletes the stream, best effort, when the page is unloaded for good (pagehide, not when it enters the back/forward cache), with a keepalive request. It is off by default, and we suggest leaving it off: a stream kept between visits holds the events that arrive while no tab is open, which catchUp exists to deliver, and a browser may drop a request sent while a page unloads.
10. Sign out
async function signOut() {
const tokenSet = await tokens.getTokenSet('user');
await receiver.stop({ deleteStream: true }).catch(() => {});
const logout = await client.createEndSessionRequest({ idTokenHint: tokenSet.id_token });
await tokens.delete('user', { revoke: true }); // revokes at the provider (RFC 7009)
clearAppState(); // including any CAEP events you stored
location.assign(logout.url);
}
// On the post-logout page:
if (new URLSearchParams(location.search).has('state')) {
await client.validateEndSessionCallback(location.href);
history.replaceState(null, '', '/');
}
With AccountManager, accounts.signOut(key) does the revocation and builds the logout request for that account only. Its signed-out event is a good place to clear that account’s data.
11. Errors
Every error the library throws is an OIDCClientError with a code.
| Class | When | Examples of code |
|---|---|---|
OAuthError |
The provider answered with an OAuth error. error_description is in the message. |
login_required, consent_required, account_selection_required, invalid_grant, invalid_scope, use_dpop_nonce |
ValidationError |
A response or token failed a check. | state_missing, state_mismatch, state_reused, transaction_expired, issuer_mismatch, nonce_mismatch, invalid_id_token, acr_mismatch, account_mismatch, front_channel_token |
SETValidationError |
A security event was refused. Reported to onError. |
invalid_key, invalid_audience |
SSFError |
The transmitter can’t be used as configured. | no poll delivery, no stream |
OIDCClientError |
Everything else. | network_error, http_error, insecure_endpoint, pkce_not_supported, public_client_not_supported, no_refresh_token |
needsInteraction(err) is true when only a visible sign-in can fix the problem: login_required, interaction_required, consent_required or account_selection_required. When it’s true, send the person to sign in rather than retrying silently.
12. Security checklist
- The client is public. The app holds no secret.
- DPoP is on. With persistent storage, the key is in IndexedDB (non-extractable), or you accept an extractable per-tab key.
- The CSP has no inline script and no
eval, andframe-ancestors 'none'is set. Referrer-Policy: no-referreris set, and the callback strips the code from the URL (history.replaceState).- The provider allows only your exact redirect URIs and your origin for CORS.
- The app asks for only the scopes it uses.
ssf.manageis only for apps that manage their own stream. - The receiver catches up (
start({ catchUp: true })) before a restored session is used. - CAEP handlers check
subjectMatches()before acting, and fail safe. - Signing out revokes tokens, deletes or stops the stream, and clears any stored events.
13. Troubleshooting
| Symptom | Likely cause |
|---|---|
network_error or “Failed to fetch” on discovery, token or SSF calls |
The provider doesn’t allow your origin (CORS), or the browser doesn’t trust its certificate. Open the provider URL in a tab to check the certificate. |
insecure_endpoint / insecure_redirect_uri |
An endpoint or redirect URI isn’t https. Only loopback may use http. |
public_client_not_supported |
The provider’s metadata doesn’t list none in token_endpoint_auth_methods_supported. |
state_mismatch or transaction_expired on the callback |
The sign-in started in another tab or browser, took longer than 10 minutes, or the callback page was reloaded. Start again. |
invalid_scope, or a scope missing from the token |
The client isn’t allowed that scope, or (iya-sts) the person or application lacks the role. iya-sts logs STS-ADMIN-0821 saying which condition failed. |
| The SSF calls answer 401 or 403 | The access token lacks ssf.read / ssf.manage, or the stream belongs to somebody else. |
| No events arrive | The stream doesn’t ask for that type (receiver.stream.events_delivered), has no subject, or isn’t polling (onError with phase: 'poll'). |
account_mismatch |
A request that named an account came back signed in as somebody else. The tokens were revoked; ask the person to sign in as the named account. |
To check a setup end to end, run the demo against your provider (docker compose up --build, then open http://localhost:3000), and send test events from the provider’s admin console or API.