wsfed-caep-nodejs
A WS-Federation relying party for server-side Node.js applications: the passive requestor profile of WS-Federation 1.2 (wsignin1.0, wsignout1.0, wsignoutcleanup1.0), with the token a SAML 2.0 assertion, validated strictly and decrypted when it is encrypted, and a CAEP and RISC receiver that ends the application’s sessions when the identity provider says so. Written in TypeScript, usable from JavaScript (ESM, or require() on Node 22.12+). Two runtime dependencies: xml-crypto and @xmldom/xmldom. Everything else is node:crypto and node:https.
Documentation: https://rcbj.github.io/wsfed-caep-nodejs/ (guide, Shared Signals handlers, API reference)
npm install wsfed-caep-nodejs
It speaks no web framework. The calls take and return plain values, so it fits Express, Fastify, Koa or node:http alike. See the server-side guide for a walkthrough, and examples/node-http.mjs for a complete application in plain JavaScript.
Quick look
import { RelyingParty, WsFedError } from 'wsfed-caep-nodejs';
const rp = await RelyingParty.fromFile('wsfed.json');
// GET /login: to the identity provider with wa=wsignin1.0. Keep request.id with the browser until it comes back.
const request = rp.login({ relayState: '/' });
// redirect to request.url
// POST /wsfed (the reply URL): the identity provider's form posts wa, wresult and wctx here.
try {
const who = await rp.handleReply({ method: req.method, url: req.url, body }, { requestId });
// who.nameId, who.attributes, who.sessionIndex, who.relayState, ...
} catch (err) {
if (err instanceof WsFedError) console.warn(err.code, err.message);
}
// GET /wsfed?wa=wsignoutcleanup1.0: the identity provider signing the person out of every relying party.
if (rp.isSignOutCleanup({ method: req.method, url: req.url })) { /* end the session; answer with an image */ }
// GET /FederationMetadata/2007-06/FederationMetadata.xml: this relying party's metadata.
res.end(rp.metadata());
From JavaScript it is the same without the types:
import { RelyingParty } from 'wsfed-caep-nodejs'; // ESM
const { RelyingParty } = require('wsfed-caep-nodejs'); // CommonJS, Node 22.12+
The configuration
{
"realm": "https://app.example.com/",
"replyUrl": "https://app.example.com/wsfed",
"idpMetadata": "https://idp.example.com/FederationMetadata/2007-06/FederationMetadata.xml"
}
| Setting | Default | |
|---|---|---|
realm |
required | This relying party’s realm: the wtrealm it sends, the audience every assertion must name, and its metadata’s entityID. |
replyUrl |
required | The reply URL (wreply): where the identity provider posts the token, and sends wsignoutcleanup1.0. |
idpMetadata |
required | The identity provider’s federation metadata: a file path, an https URL, or the XML. Its entityID, passive requestor endpoint, token types and signing certificates are read from it. |
privateKey, certificate |
— | An RSA key and its certificate (file paths relative to the configuration file, or the PEM). Encrypted assertions are decrypted with it, the metadata publishes it as the encryption key, and the Shared Signals receiver can present it as a TLS client certificate. WS-Federation itself signs nothing. |
wantAssertionsEncrypted |
false |
Refuse an assertion that is not encrypted. Needs the key pair. |
requestTokenType |
true |
Ask for a SAML 2.0 assertion with a wreq RequestSecurityToken naming its TokenType. Turn off only for an identity provider that refuses wreq and issues SAML 2.0 to this realm by itself. |
idpCa |
— | PEM certificate(s) to trust for TLS to the identity provider (the metadata URL), in addition to node’s own store. |
idpEntityId |
— | For metadata that is an aggregate: which identity provider. |
clockSkewSeconds |
60 |
How far the two clocks may disagree. |
allowUnsolicited |
false |
Accept a sign-in response no request asked for (identity-provider-initiated sign-in). |
RelyingParty.create(config, { baseDir }) takes the same members as an object.
The protocol
| Message | This library | |
|---|---|---|
wsignin1.0 request |
The browser is redirected to the identity provider’s PassiveRequestorEndpoint with wtrealm, wreply, wctx, wct, and wreq asking for urn:oasis:names:tc:SAML:2.0:assertion. Optionally wfresh (forceAuthn is wfresh=0), wauth (authenticationMethod), whr. |
rp.login() |
wsignin1.0 response |
The identity provider’s page POSTs wa, wresult (a WS-Trust RequestSecurityTokenResponse around the assertion) and wctx to the reply URL. |
rp.handleReply() |
wsignout1.0 |
The browser is sent to the identity provider to end its session there. | rp.logout({ reply }) |
wsignoutcleanup1.0 |
The identity provider loads the reply URL as an image, so the relying party ends its session. | rp.isSignOutCleanup() |
wauth. Pass it as authenticationMethod to login(). The value is one URI. Two factors is http://schemas.microsoft.com/claims/multipleauthn:
rp.login({
relayState: '/',
authenticationMethod: 'http://schemas.microsoft.com/claims/multipleauthn',
});
The guide walks through the call. iya-sts treats a demand the session does not already meet as a step-up, and the URIs it accepts are on its WS-Federation page.
The response is read in either WS-Trust shape: a February 2005 RequestSecurityTokenResponse (what AD FS and iya-sts send by default), or a WS-Trust 1.3 RequestSecurityTokenResponseCollection holding one. wresultptr is refused: a reference is never dereferenced.
wctx correlates the response with the request. A WS-Federation sign-in request is not signed, and the assertion answers no request id. The one value the identity provider returns unchanged is wctx, so login() puts the request’s id there (id=<id>&ru=<relayState>, the shape WIF uses) and handleReply() requires it to be one this browser has outstanding. That stops a response from being pushed into a browser that never asked for one (login CSRF). It is not what makes the sign-in genuine: the assertion’s signature, audience and lifetime are.
What it checks
Every sign-in response:
- No DOCTYPE, and no two elements sharing an ID (by local name, so
wsu:Idcounts), which is how a signature is made to point at the wrong element. - The response:
wa=wsignin1.0, POSTed, withwresult; exactly one RequestSecurityTokenResponse, itsAppliesTo(when present) this realm, itsLifetimenot passed, itsTokenType(when present) SAML 2.0, and exactly one token in itsRequestedSecurityToken. - A SAML 2.0 assertion. A SAML 1.1 assertion, the default token many identity providers issue to a WS-Federation relying party, is refused with a message saying how to ask for SAML 2.0.
- The assertion’s signature, always required, against the identity provider’s own certificates from its metadata, never against a certificate the message carries. In WS-Federation the response around the assertion is not signed, so the assertion’s signature is the only one. RSA-SHA256/384/512 only; SHA-1 is refused. It must be an enveloped signature of the assertion: one reference, to its ID, with only the enveloped and canonicalization transforms.
- What was signed is what is read. Once the signature verifies, the assertion is parsed from the XML that was signed, so a forged element beside it is not seen.
- The assertion: the identity provider as Issuer; a bearer
SubjectConfirmationthat has not expired, has noNotBefore, and has noInResponseTo(there was no AuthnRequest; one that answers a SAML request belongs to a SAML Web SSO exchange, not to this one) and whoseRecipient, if the identity provider sets one, is the reply URL;Conditionsin their time window; this realm in everyAudienceRestriction; anAuthnStatement, itsSessionNotOnOrAfternot passed. - Once only: the assertion’s ID is remembered until it could no longer be presented. The default
MemoryReplayCacheis one process’s. GiveRelyingPartya sharedreplayCachewhen the application runs several.
Encryption
An EncryptedAssertion, or a bare xenc:EncryptedData in the RequestedSecurityToken (how AD FS encrypts a WS-Federation token), is decrypted with node:crypto, and so are EncryptedID and EncryptedAttribute: AES-GCM or AES-CBC content, the key wrapped with RSA-OAEP (rsa-oaep-mgf1p, or XML Encryption 1.1 rsa-oaep with one hash for digest and MGF1). RSA PKCS#1 v1.5 key transport is refused. The metadata publishes the encryption key and the algorithms accepted. The assertion inside must still be signed.
Shared Signals: CAEP and RISC
SignalsReceiver makes the application a Shared Signals receiver (OpenID SSF 1.0, poll delivery per RFC 8936). It has one stream at the identity provider, carrying CAEP session events and RISC account events about every person who uses the application. Each event is turned into the application’s own sessions to end. WS-Federation’s own sign-out reaches the relying party only through the browser (wsignoutcleanup1.0, an image load a browser may block); a CAEP event reaches it whether or not the browser is open.
import { EVENT, RelyingParty, SignalsReceiver } from 'wsfed-caep-nodejs';
const rp = await RelyingParty.fromFile('wsfed.json');
const signals = new SignalsReceiver({
transmitter: 'https://idp.example.com',
// Mutual TLS with the RP's own key pair: no token, no secret.
tls: { ca: rp.config.idpCa, cert: rp.config.certificate!.toString(),
key: rp.config.privateKey!.export({ type: 'pkcs8', format: 'pem' }) as string },
onEndSessions: (localIds, event) => { for (const id of localIds) sessionStore.destroy(id); },
});
signals.on(EVENT.credentialChange, (event) => console.log('credential changed:', event.payload.credential_type));
signals.on('*', (event) => console.log(event.type, event.subject));
await signals.start();
// at the reply URL, after the session is created:
await signals.track(session.id, who);
// at sign-out:
await signals.untrack(session.id);
- One stream, found or created.
start()reads/.well-known/ssf-configuration. It reuses the application’s poll stream (GETon the configuration endpoint lists the caller’s streams) or creates one, then polls. - Every SET is verified before anything acts on it: the signature against the transmitter’s JWKS (RS, PS, ES, EdDSA; the key chosen by
kid, never from the token),iss,aud(the stream’s),typandjti. A SET is acknowledged only once handled. One it refuses is reported insetErrsand changes nothing. - Matching events to sessions.
track()records three things from the sign-in:- the person’s subject, read from the claim
subjectAttributenames, which defaults to thenameidentifierclaim iya-sts and AD FS issue (urn:uuid:…on iya-sts); - their email address (the
emailaddressclaim, among others); - the IdP session, the assertion’s
SessionIndex, which is the session id a CAEP event names.
A
session-revokednaming an IdP session ends only the local sessions started from it. An account event (account-disabled,account-purged,credential-compromise,sessions-revoked) ends all of the person’s local sessions.endOnchanges which types end sessions; every event also goes to theon(type)handlers. - the person’s subject, read from the claim
- Handlers, one per event type.
on(EVENT.x, handler)for any of CAEP’s eight and RISC’s fourteen types (EVENT,CAEP_EVENTS,RISC_EVENTS), or'*'for all. Handlers run after the SET is verified and before it is acknowledged. One that throws is logged, and the SET is still acknowledged. Handling CAEP and RISC events lists every type, its members, its default and a handler for each. - Several processes. The default
MemorySessionIndexis one process’s memory. Give every process the samesessionIndex(a table, Redis), and run the receiver in one of them, so an event ends sessions wherever they were created. - Authentication. Use
tls.cert/tls.key(mutual TLS), orheaders()to supply anauthorizationheader for a transmitter that wants a token.
Errors
Every failure is a WsFedError with a code: config, metadata, message (not a sign-in response this relying party reads), xml, signature, validation, replay, decryption or ssf (the Shared Signals transmitter or stream). Log the message; tell the person only that sign-in failed.
The command line
npx wsfed-caep-nodejs generate-keys --config wsfed.json # a new RSA key and a self-signed certificate
npx wsfed-caep-nodejs metadata --config wsfed.json --out rp-metadata.xml
generate-keys writes where the configuration’s privateKey and certificate point (or --key/--cert), refuses to replace them without --force, and takes --cn, --days (1095) and --bits (3072).
API
RelyingParty.fromFile(path, overrides?, options?) |
Read a JSON configuration (paths relative to it) and load the IdP metadata. |
RelyingParty.create(config, { baseDir?, replayCache? }) |
The same from an object. |
rp.login({ relayState?, forceAuthn?, freshness?, authenticationMethod?, homeRealm?, params? }) |
A wsignin1.0 request: { id, url, wctx }. Redirect to url; keep id. params adds query parameters of the identity provider’s own (e.g. login_hint). |
rp.handleReply({ method, url, body? }, { requestId? }) |
Validate the sign-in response; resolves to a WsFedLogin. body is the raw form text or a parsed object. |
rp.isSignOutCleanup({ method, url, body? }) |
Whether the request is wsignoutcleanup1.0 (or wsignout1.0). |
rp.logout({ reply? }) |
The wsignout1.0 URL. |
rp.metadata() |
This relying party’s federation metadata. |
rp.refreshMetadata() |
Read the IdP metadata again (a key rollover). |
rp.idp, rp.config |
What was read. |
MemoryReplayCache, ReplayCache |
The default replay cache, and the interface for a shared one (checkAndRecord(id, expiresAt)). |
WsFedError |
Every failure. |
WsFedLogin: nameId, nameIdFormat, nameQualifier, spNameQualifier, sessionIndex, sessionNotOnOrAfter, authnInstant, authnContextClassRef, attributes (claim URI → values), attributeFriendlyNames, issuer, realm, requestId, relayState, trustVersion ('2005/02' or '1.3'), encrypted, assertionId, notOnOrAfter.
License
MIT © 2026 Iya CyberSecurity Solutions, LLC. See LICENSE.