Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
3547cee
refactor!: remove parse()
deepakprabhakara Oct 12, 2026
3ae98dd
refactor!: stop exporting decryptXml and PubKeyInfo from the package …
deepakprabhakara Oct 12, 2026
3153c74
refactor!: remove the deprecated hasValidSignature export
deepakprabhakara Oct 12, 2026
dae6549
style: prettier on compat.spec.ts
deepakprabhakara Oct 12, 2026
60aab76
refactor!: stop exporting isMultiRootedXMLError from utils
deepakprabhakara Oct 12, 2026
1eaf627
test: rebaseline compat.spec.ts on the 2.0.0 exports
deepakprabhakara Oct 12, 2026
9125fa7
fix!: match audiences exactly and remove strictAudienceValidation
deepakprabhakara Oct 12, 2026
b9bb8e8
feat!: export only the package root
deepakprabhakara Oct 12, 2026
0f5b74a
refactor: drop the certToPEM re-export from validateSignature
deepakprabhakara Oct 12, 2026
188b2a7
fix!: require an audience in validate()
deepakprabhakara Oct 12, 2026
bcd7720
fix!: make validateSignature() throw instead of returning null
deepakprabhakara Oct 12, 2026
fb54843
refactor: drop the RequestSecurityTokenResponse branch from parseResp…
deepakprabhakara Oct 12, 2026
33f9378
test: cover the response.ts paths that parse() used to reach
deepakprabhakara Oct 12, 2026
addcc3b
test: drop the unused fixture and the malformed Okta certificate
deepakprabhakara Oct 12, 2026
44c0bd6
docs: replace the BoxyHQ leftovers in the README and package.json
deepakprabhakara Oct 12, 2026
b5a44c8
fix: restore WS-Trust RequestSecurityTokenResponse support for ADFS
deepakprabhakara Oct 12, 2026
9545aea
Merge branch 'main' into feat/remove-deprecated-api
deepakprabhakara Oct 24, 2026
bdce68d
test: pin that validate() canonicalizes no Reference of an inflated S…
deepakprabhakara Oct 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 20 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
SAML 2.0 & 1.1 Assertion Parser & Validator
# SAML 2.0 parser and validator

![Build Status](https://github.com/boxyhq/saml20/actions/workflows/node.js.yml/badge.svg)
![Build Status](https://github.com/ory/saml20/actions/workflows/node.js.yml/badge.svg)

=============

boxyhq/saml20 is a fork of a fork of [saml20](https://github.com/leandrob/saml20). It now has extended functionality and diverges from the original unmaintained library. The new package is published here - https://www.npmjs.com/package/@boxyhq/saml20
A SAML 2.0 library for Node.js: it validates SAML Responses, and creates and parses AuthnRequests, logout messages and metadata. It started as a fork of [saml20](https://github.com/leandrob/saml20) and has since diverged from it. Ory maintains it, and it is published on npm as [`@boxyhq/saml20`](https://www.npmjs.com/package/@boxyhq/saml20).

## Installation

Expand All @@ -14,7 +12,7 @@ $ npm install @boxyhq/saml20

## Usage

`parse()` and `validate()` return promises. Before 1.6.0 they took a callback as their last argument; since 1.6.0 a callback is ignored and is never called, so `await` the result or use `.then()`/`.catch()` instead.
`validate()` returns a promise.

### saml.validate(rawResponse, options)

Expand All @@ -27,8 +25,7 @@ Verifies a SAML Response (or a lone Assertion) and resolves with the user's prof
- `publicKey`: the trusted IdP signing certificate, as base64 DER or PEM. Several certificates (for key rotation) are separated by commas.
- `thumbprint`: the SHA-1 thumbprint(s) of a trusted certificate, comma-separated. The certificate itself is taken from the `KeyInfo` of the signature being verified. Pass either `publicKey` or `thumbprint`, not both.
- `privateKey` (optional): decrypts an `EncryptedAssertion`.
- `audience` (optional): the expected audience, or a list of accepted ones. When set, every `AudienceRestriction` in the assertion must name one of them.
- `strictAudienceValidation` (optional): require an exact audience match. Without it, an audience that merely starts with the expected value is accepted.
- `audience`: the expected audience, or a list of accepted ones. Every `AudienceRestriction` in the assertion must name one of them exactly. Fails with `Invalid audience.` A missing or empty value fails with `audience is required.`
- `inResponseTo` (optional): the ID of the `AuthnRequest` this response answers. When set, the signed content must carry the same `InResponseTo`; a missing value fails. Fails with `Invalid InResponseTo.`
- `recipient` (optional). The ACS URL the response was posted to. When set, the signed content must name it: a signed `Response/@Destination` must equal it, when any bearer `SubjectConfirmationData` carries a `Recipient`, one bearer confirmation must both name it and be within its own `NotBefore`/`NotOnOrAfter` window (so `NotOnOrAfter` is required on it; `bypassExpiration` skips this window check but not the `Recipient` match), and at least one of the two must be present. An empty `Recipient` counts as present and never matches. The unsigned `Response` wrapper is never used. Fails with `Invalid Recipient.`
- `assertionReplayValidator` (optional): `(info) => boolean | Promise<boolean>`, called after every other check passes, with `{ assertionId, sessionIndex, notOnOrAfter, inResponseTo }` from the signed assertion. Return `true` if the assertion was already used, and the response is rejected as a replay. Keep each entry until at least `notOnOrAfter` plus 10 minutes, the clock skew `validate()` allows.
Expand All @@ -42,7 +39,6 @@ const saml = require('@boxyhq/saml20').default;
const profile = await saml.validate(rawResponse, {
publicKey: 'MIICDzCCAXygAwIBAgIQVWXAvbbQyI5Bc...',
audience: 'https://sp.example.com',
strictAudienceValidation: true,
inResponseTo: requestId,
recipient: 'https://sp.example.com/acs',
});
Expand All @@ -60,13 +56,9 @@ Returns the `Issuer` of a SAML Response without validating anything. Use it to f
const issuer = saml.parseIssuer(rawResponse);
```

### [DEPRECATED] saml.parse(rawResponse)

Resolves with the same profile as `validate()`, but checks nothing: not the signature, expiry, audience or anything else. Anyone can forge its output, so never use it to authenticate a user. Use `validate()` for that, and `parseIssuer()` to read the Issuer before validating.

### saml.validateSignature(xml, publicKey, thumbprint, options?)

Verifies the XML signature on `xml` and returns the signed XML, or throws / returns `null` when the signature is invalid. Pass either `publicKey` (one certificate, or several separated by commas for key rotation) or `thumbprint`, not both.
Verifies the XML signature on `xml` and returns the signed XML. Read the document from that return value, not from `xml`: only the signed content is covered. It throws when the signature is invalid or cannot be checked; a signature that does not verify fails with `invalid signature: the signature does not verify`. Pass either `publicKey` (one certificate, or several separated by commas for key rotation) or `thumbprint`, not both.

`options` (optional) restricts which algorithms are accepted:

Expand All @@ -92,6 +84,19 @@ var signedXml = saml.validateSignature(xml, publicKey, null, {
});
```

## Upgrading to 2.0

2.0 removes APIs that were deprecated or that Polis no longer uses:

- Only the package root can be imported. `package.json` now has an `exports` map, so paths inside the package such as `@boxyhq/saml20/dist/typings` fail with `ERR_PACKAGE_PATH_NOT_EXPORTED` (and with TS2307 under `moduleResolution` `node16`, `nodenext` or `bundler`). Import from the root instead, for example `import type { SAMLProfile } from '@boxyhq/saml20'`. Everything the root exports is listed in `test/lib/compat.spec.ts`.
- `parse()` is gone. It returned a profile without checking the signature, expiry, audience or anything else, so its output could be forged. Use `validate()` to authenticate a response, and `parseIssuer()` to read the Issuer before validating (for example, to pick the IdP certificate).
- `decryptXml` and `PubKeyInfo` are no longer exported from the package root. They were internal helpers for `validate()` and `sign()`.
- `hasValidSignature` is removed. Use `validateSignature()`, which also rejects being given both a certificate and a thumbprint.
- `isMultiRootedXMLError` is removed. A multi-rooted document is rejected with the error `multirooted xml not allowed.`
- `strictAudienceValidation` is gone and audiences are always compared exactly. Before 2.0, without that option an `Audience` that merely started with the expected value was accepted (`https://sp.example.com/metadata.evil.example` passed for `https://sp.example.com/metadata`). If you relied on a prefix match, pass every accepted audience in `audience` instead.
- `validateSignature()` throws when the signature does not verify, where it used to return `null`. It now either returns the signed XML or throws.
- `audience` is required. Before 2.0, leaving it out skipped the audience check, so an assertion issued for any other service provider that trusts the same IdP was accepted.

## Tests

```bash
Expand All @@ -106,18 +111,13 @@ Fixtures live in `test/assets`. Tests that need a fresh validity window, or a sh

Thanks for taking the time to contribute! Contributions are what make the open-source community such an amazing place to learn, inspire, and create. Any contributions you make will benefit everybody and are appreciated.

Please try to create bug reports that are:
Report bugs in [GitHub issues](https://github.com/ory/saml20/issues). Please try to create bug reports that are:

- _Reproducible._ Include steps to reproduce the problem.
- _Specific._ Include as much detail as possible: which version, what environment, etc.
- _Unique._ Do not duplicate existing opened issues.
- _Scoped to a Single Bug._ One bug per report.

## Community

- [Discord](https://discord.gg/uyb7pYt4Pa) (For live discussion with the Community and BoxyHQ team)
- [Twitter](https://twitter.com/BoxyHQ) (Get the news fast)

## Reporting Security Issues

[Responsible Disclosure](SECURITY.md)
Expand Down
13 changes: 3 additions & 10 deletions lib/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { validateSignature, certToPEM, DEFAULT_MAX_SIGNATURE_REFERENCES } from './validateSignature';
import { validateSignature, DEFAULT_MAX_SIGNATURE_REFERENCES } from './validateSignature';
import type { ValidateSignatureOptions } from './validateSignature';

import {
Expand All @@ -11,12 +11,11 @@ import {
MAX_INFLATED_REQUEST_BYTES,
} from './request';
import type { DecodeBase64Options } from './request';
import { stripCertHeaderAndFooter, PubKeyInfo } from './cert';
import { createSAMLResponse, parse, validate, parseIssuer, WrapError } from './response';
import { stripCertHeaderAndFooter, certToPEM } from './cert';
import { createSAMLResponse, validate, parseIssuer, WrapError } from './response';
import { parseMetadata, createIdPMetadataXML, createSPMetadataXML } from './metadata';
import { createPostForm } from './post';
import { sign } from './sign';
import { decryptXml } from './decrypt';
import { containsDoctype } from './utils';
import { parseLogoutResponse, createLogoutRequest, parseLogoutRequest, createLogoutResponse } from './logout';
import type {
Expand All @@ -38,16 +37,13 @@ export default {
samlRequestDecodeError,
MAX_ENCODED_REQUEST_LENGTH,
MAX_INFLATED_REQUEST_BYTES,
parse,
validate,
PubKeyInfo,
certToPEM,
stripCertHeaderAndFooter,
createPostForm,
sign,
validateSignature,
DEFAULT_MAX_SIGNATURE_REFERENCES,
decryptXml,
parseIssuer,
WrapError,
containsDoctype,
Expand All @@ -71,16 +67,13 @@ export {
samlRequestDecodeError,
MAX_ENCODED_REQUEST_LENGTH,
MAX_INFLATED_REQUEST_BYTES,
parse,
validate,
PubKeyInfo,
certToPEM,
stripCertHeaderAndFooter,
createPostForm,
sign,
validateSignature,
DEFAULT_MAX_SIGNATURE_REFERENCES,
decryptXml,
parseIssuer,
WrapError,
containsDoctype,
Expand Down
51 changes: 16 additions & 35 deletions lib/response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import xml2js from 'xml2js';
import xmlbuilder from 'xmlbuilder';
import { getVersion } from './getVersion';
import { validateSignature, sanitizeXML } from './validateSignature';
import { verifySignature, sanitizeXML } from './validateSignature';
import type { ValidateSignatureOptions } from './validateSignature';
import { decryptXml } from './decrypt';
import { select } from 'xpath';
Expand All @@ -26,31 +26,6 @@
'urn:mace:dir:attribute-def:jpegPhoto',
];

/**
* Parses a SAML Response or Assertion into a profile WITHOUT checking its
* signature, expiry, audience or anything else. Its output can be forged by
* whoever sends the document.
*
* @deprecated Use validate() to authenticate a response. To read the Issuer
* before validating (for example, to choose which IdP certificate to pass to
* validate()), use parseIssuer().
*/
const parse = async (rawAssertion: string): Promise<SAMLProfile> => {
if (!rawAssertion) {
throw new Error('rawAssertion is required.');
}

const assertionObj = await xmlToJs(rawAssertion);

const statusError = getStatusCodeError(assertionObj);
if (statusError) {
throw statusError;
}

const { assertion } = parseResponseAndVersion(assertionObj);
return parseAttributes(assertion);
};

const parseIssuer = (rawAssertion) => {
if (!rawAssertion) {
throw new Error('rawAssertion is required.');
Expand Down Expand Up @@ -82,6 +57,14 @@
throw new Error('You should provide either cert or certThumbprint, not both');
}

const audiences: unknown[] = Array.isArray(options.audience) ? options.audience : [options.audience];
if (
audiences.length === 0 ||
!audiences.every((audience) => typeof audience === 'string' && audience !== '')
) {
throw new Error('audience is required.');
}

// Optional algorithm allowlists and the Reference ceiling, forwarded to
// validateSignature. Leaving them out keeps the defaults.
const signatureOptions: ValidateSignatureOptions = {
Expand Down Expand Up @@ -153,7 +136,7 @@
signatureOptions: ValidateSignatureOptions
): string | null => {
try {
return validateSignature(xml, options.publicKey, options.thumbprint, signatureOptions);
return verifySignature(xml, options.publicKey, options.thumbprint, signatureOptions);
} catch (e) {
const error = new WrapError('Invalid assertion.');
error.inner = e;
Expand All @@ -175,10 +158,7 @@
return new Error('Assertion is expired.');
}

if (
options.audience &&
!saml20.validateAudience(assertion, options.audience, options.strictAudienceValidation)
) {
if (!saml20.validateAudience(assertion, options.audience)) {
return new Error('Invalid audience.');
}

Expand Down Expand Up @@ -269,9 +249,10 @@
};

/*
assertionObj: the signed content, either a whole SAML Response or a lone
Assertion. Only signed content is read here, never the unsigned <Response>
wrapper around a signed Assertion.
assertionObj: the signed content: a whole SAML Response, a lone Assertion,
or a whole WS-Trust RequestSecurityTokenResponse (ADFS over WS-Federation).
Only signed content is read here, never an unsigned wrapper around a signed
Assertion.
*/
function parseResponseAndVersion(assertionObj) {
let assertion =
Expand Down Expand Up @@ -368,7 +349,7 @@
audience: string;
issuer: string;
acsUrl: string;
claims: Record<string, any>;

Check warning on line 352 in lib/response.ts

View workflow job for this annotation

GitHub Actions / build (24)

Unexpected any. Specify a different type
requestId: string;
privateKey: string;
publicKey: string;
Expand Down Expand Up @@ -496,4 +477,4 @@
return signedXml;
};

export { createSAMLResponse, parse, validate, parseIssuer, WrapError };
export { createSAMLResponse, validate, parseIssuer, WrapError };
22 changes: 8 additions & 14 deletions lib/saml20.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,17 +140,10 @@ const parse = (assertion) => {
};
};

const audienceCheck = (audience, expectedAudience, strictValidation) => {
if (typeof audience !== 'string' || typeof expectedAudience !== 'string') {
return false;
}

if (strictValidation) {
return audience === expectedAudience;
}

return audience.startsWith(expectedAudience);
};
// Audiences are compared exactly. A prefix match would accept an assertion
// issued for any identifier that merely starts with the relying party's.
const audienceCheck = (audience, expectedAudience) =>
typeof audience === 'string' && typeof expectedAudience === 'string' && audience === expectedAudience;

const toArray = <T>(value: T | T[] | undefined | null): T[] => {
if (value === undefined || value === null) {
Expand All @@ -169,8 +162,9 @@ const getAudienceRestrictions = (assertion): unknown[][] =>
// SAML core 2.5.1.4: an assertion may carry several <AudienceRestriction>
// elements, and each must be satisfied; within one <AudienceRestriction> the
// <Audience> values are alternatives, so any one of them may name the relying
// party. `realm` is the relying party's own identifier, or a list of them.
const validateAudience = (assertion, realm, strictValidation = false) => {
// party. `realm` is the relying party's own identifier, or a list of them;
// an Audience must equal one of them exactly.
const validateAudience = (assertion, realm) => {
try {
const expected = toArray(realm);
const restrictions = getAudienceRestrictions(assertion);
Expand All @@ -180,7 +174,7 @@ const validateAudience = (assertion, realm, strictValidation = false) => {

return restrictions.every((audiences) => {
return audiences.some((audience) =>
expected.some((expectedAudience) => audienceCheck(audience, expectedAudience, strictValidation))
expected.some((expectedAudience) => audienceCheck(audience, expectedAudience))
);
});
// eslint-disable-next-line @typescript-eslint/no-unused-vars
Expand Down
7 changes: 3 additions & 4 deletions lib/typings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
audience?: string;
// Every <Audience> of every <AudienceRestriction>, in document order.
audiences: string[];
claims: Record<string, any>;

Check warning on line 27 in lib/typings.ts

View workflow job for this annotation

GitHub Actions / build (24)

Unexpected any. Specify a different type
issuer: string;
sessionIndex: string;
// The assertion's Subject NameID exactly as asserted (not trimmed), with its
Expand Down Expand Up @@ -65,10 +65,9 @@
thumbprint?: string;
// Private key that decrypts an EncryptedAssertion.
privateKey?: string;
// Expected audience, or several accepted audiences.
audience?: string | string[];
// Require an exact audience match instead of a prefix match.
strictAudienceValidation?: boolean;
// Expected audience, or several accepted audiences. Every
// AudienceRestriction must name one of them exactly.
audience: string | string[];
// ID of the AuthnRequest this response answers (SP-initiated flows). When
// set, the signed content must carry the same InResponseTo.
inResponseTo?: string;
Expand Down
2 changes: 0 additions & 2 deletions lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,9 +122,9 @@
return shasum.digest('hex');
};

const getAttribute = <TDefault = unknown>(value: any, path: string, defaultValue?: TDefault): TDefault => {

Check warning on line 125 in lib/utils.ts

View workflow job for this annotation

GitHub Actions / build (24)

Unexpected any. Specify a different type
const segments = path.split(/[\.\[\]]/g); // eslint-disable-line no-useless-escape
let current: any = value;

Check warning on line 127 in lib/utils.ts

View workflow job for this annotation

GitHub Actions / build (24)

Unexpected any. Specify a different type
for (const key of segments) {
if (current === null) return defaultValue as TDefault;
if (current === undefined) return defaultValue as TDefault;
Expand All @@ -136,9 +136,8 @@
return current;
};

/** @deprecated An internal helper; it stops being exported in the next major version. */
const isMultiRootedXMLError = (err: any) => {

Check warning on line 139 in lib/utils.ts

View workflow job for this annotation

GitHub Actions / build (24)

Unexpected any. Specify a different type
if ((err as any)?.message?.indexOf('Only one element can be added and only after doctype') >= 0) {

Check warning on line 140 in lib/utils.ts

View workflow job for this annotation

GitHub Actions / build (24)

Unexpected any. Specify a different type
return true;
}
return false;
Expand All @@ -150,7 +149,6 @@
parseFromString,
thumbprint,
getAttribute,
isMultiRootedXMLError,
multiRootedXMLError,
doctypeNotAllowedError,
containsDoctype,
Expand Down
33 changes: 15 additions & 18 deletions lib/validateSignature.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,16 +159,7 @@ const assertBoundedReferences = (signature, options?: ValidateSignatureOptions)
}
};

/**
* @deprecated Use validateSignature, which also rejects being given both a
* certificate and a thumbprint. It stops being exported in the next major version.
*/
const hasValidSignature = (xml, cert, certThumbprint, options?: ValidateSignatureOptions): string | null => {
xml = sanitizeXML(xml);
return _hasValidSignature(xml, cert, certThumbprint, options);
};

const _hasValidSignature = (xml, cert, certThumbprint, options?: ValidateSignatureOptions): string | null => {
const doc = parseFromString(xml);
let signature =
select(
Expand Down Expand Up @@ -268,22 +259,28 @@ const _hasValidSignature = (xml, cert, certThumbprint, options?: ValidateSignatu
return null;
};

// Breaking Change: validateSignature now returns:
// if signature is valid: the raw signed xml string
// if signature is invalid: throws error or returns null
// clients are to use the resultant raw xml string to parse their SAML Assertion

const validateSignature = (xml, cert, certThumbprint, options?: ValidateSignatureOptions) => {
// Returns null when the signature does not verify, and throws when the
// document cannot be checked at all; validate() retries only on null.
const verifySignature = (xml, cert, certThumbprint, options?: ValidateSignatureOptions): string | null => {
if (cert && certThumbprint) {
throw new Error('You should provide either cert or certThumbprint, not both');
}

return hasValidSignature(xml, cert, certThumbprint, options);
return hasValidSignature(sanitizeXML(xml), cert, certThumbprint, options);
};

// Returns the signed XML, which is what callers must read from; throws when
// the signature does not verify.
const validateSignature = (xml, cert, certThumbprint, options?: ValidateSignatureOptions): string => {
const signedXml = verifySignature(xml, cert, certThumbprint, options);
if (!signedXml) {
throw new Error('invalid signature: the signature does not verify');
}
return signedXml;
};

const sanitizeXML = (xml) => {
return xml.replace(/&#x(d|D);/gi, '');
};

// certToPEM is re-exported for callers that import it from this module.
export { hasValidSignature, validateSignature, certToPEM, sanitizeXML };
export { validateSignature, verifySignature, sanitizeXML };
9 changes: 8 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,16 @@
},
{
"name": "Deepak Prabhakara",
"email": "deepak@boxyhq.com"
"email": "deepak[email protected]"
}
],
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./package.json": "./package.json"
},
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
Expand Down
Loading
Loading