@orthacms/identity-provider-saml 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ortha CMS contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,7 @@
1
+ # @orthacms/identity-provider-saml
2
+
3
+ Part of [Ortha CMS](https://github.com/ortha-source/ortha-cms).
4
+
5
+ ```sh
6
+ npm install @orthacms/identity-provider-saml
7
+ ```
@@ -0,0 +1,3 @@
1
+ export { createSamlProvider } from './lib/saml-provider';
2
+ export type { SamlProviderConfig } from './lib/config';
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;AACzD,YAAY,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createSamlProvider = void 0;
4
+ var saml_provider_1 = require("./lib/saml-provider");
5
+ Object.defineProperty(exports, "createSamlProvider", { enumerable: true, get: function () { return saml_provider_1.createSamlProvider; } });
@@ -0,0 +1,85 @@
1
+ /** How one SAML identity provider is reached and read. */
2
+ export interface SamlProviderConfig {
3
+ /** The IdP's single-sign-on URL — where the AuthnRequest is sent. */
4
+ entryPoint: string;
5
+ /**
6
+ * The IdP's signing certificate(s), PEM or bare base64. Several may be
7
+ * given so a certificate rollover does not need a redeploy timed to the
8
+ * minute.
9
+ *
10
+ * There is no discovery here and no JWKS: SAML trust is a certificate an
11
+ * operator copies from their IdP, which is why it is required and why a
12
+ * rollover is a configuration change rather than something the adapter can
13
+ * pick up on its own.
14
+ */
15
+ idpCert: string | string[];
16
+ /**
17
+ * This CMS's entity id — the `Issuer` on the AuthnRequest, and what the IdP
18
+ * has registered as the service provider.
19
+ */
20
+ issuer: string;
21
+ /** Button text. Defaults to `SAML`. */
22
+ label?: string;
23
+ /**
24
+ * The attribute holding a **stable** identifier for the person, when the
25
+ * `NameID` is not one.
26
+ *
27
+ * Worth setting whenever the IdP's NameID format is `emailAddress`: an
28
+ * address is not a stable identifier, and the core refuses a profile whose
29
+ * subject is one. Point this at an immutable directory id instead.
30
+ */
31
+ subjectAttribute?: string;
32
+ /**
33
+ * The attribute holding the email address. Defaults to trying the usual
34
+ * spellings, which differ per IdP more than anything else in SAML does.
35
+ */
36
+ emailAttribute?: string;
37
+ /** The attribute holding group membership, when a deployment maps roles. */
38
+ groupsAttribute?: string;
39
+ /** The attribute holding a display name. */
40
+ nameAttribute?: string;
41
+ /**
42
+ * Whether an address this IdP asserts counts as **verified**.
43
+ *
44
+ * Defaults to `false`, and this is the setting most likely to be reached
45
+ * for. **SAML has no verification claim at all** — no assertion carries the
46
+ * equivalent of `email_verified`, so there is nothing an adapter could read
47
+ * and be honest about. Setting this is an operator asserting that their
48
+ * directory is authoritative for the addresses it reports, which is usually
49
+ * true of a corporate IdP and is still not something to assume on their
50
+ * behalf: it is the only gate on a first sign-in claiming an existing
51
+ * account.
52
+ */
53
+ emailVerified?: boolean;
54
+ /** The SP private key, when the IdP requires signed AuthnRequests. */
55
+ privateKey?: string;
56
+ /** The SP certificate that goes with {@link privateKey}. */
57
+ signingCert?: string;
58
+ /** Whether to require the IdP to sign its assertions. Defaults to `true`. */
59
+ wantAssertionsSigned?: boolean;
60
+ /** Whether to require a signed response envelope. Defaults to `true`. */
61
+ wantAuthnResponseSigned?: boolean;
62
+ /** Accepted clock skew, in seconds. Defaults to 60. */
63
+ clockToleranceSeconds?: number;
64
+ }
65
+ /** Defaults applied once, so no code path has to remember them. */
66
+ export interface ResolvedSamlConfig extends SamlProviderConfig {
67
+ label: string;
68
+ emailVerified: boolean;
69
+ wantAssertionsSigned: boolean;
70
+ wantAuthnResponseSigned: boolean;
71
+ clockToleranceSeconds: number;
72
+ }
73
+ /** The attributes an email is read from, in order, when none is configured. */
74
+ export declare const DEFAULT_EMAIL_ATTRIBUTES: readonly ["email", "mail", "urn:oid:0.9.2342.19200300.100.1.3", "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress"];
75
+ /** The attributes a display name is read from, when none is configured. */
76
+ export declare const DEFAULT_NAME_ATTRIBUTES: readonly ["displayName", "cn", "name", "http://schemas.microsoft.com/identity/claims/displayname"];
77
+ /**
78
+ * Applies the defaults and rejects a configuration that cannot work.
79
+ *
80
+ * Eager, at construction, because every SSO failure looks identical by design:
81
+ * a missing certificate would otherwise reach an operator as the same blank
82
+ * "that sign-in did not complete" as a cancelled consent screen.
83
+ */
84
+ export declare function resolveSamlConfig(config: SamlProviderConfig): ResolvedSamlConfig;
85
+ //# sourceMappingURL=config.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/lib/config.ts"],"names":[],"mappings":"AAAA,0DAA0D;AAC1D,MAAM,WAAW,kBAAkB;IAC/B,qEAAqE;IACrE,UAAU,EAAE,MAAM,CAAC;IACnB;;;;;;;;;OASG;IACH,OAAO,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;IAC3B;;;OAGG;IACH,MAAM,EAAE,MAAM,CAAC;IACf,uCAAuC;IACvC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;;;;;OAOG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,4EAA4E;IAC5E,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,4CAA4C;IAC5C,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;;;;;;;;;OAWG;IACH,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,sEAAsE;IACtE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,4DAA4D;IAC5D,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,6EAA6E;IAC7E,oBAAoB,CAAC,EAAE,OAAO,CAAC;IAC/B,yEAAyE;IACzE,uBAAuB,CAAC,EAAE,OAAO,CAAC;IAClC,uDAAuD;IACvD,qBAAqB,CAAC,EAAE,MAAM,CAAC;CAClC;AAED,mEAAmE;AACnE,MAAM,WAAW,kBAAmB,SAAQ,kBAAkB;IAC1D,KAAK,EAAE,MAAM,CAAC;IACd,aAAa,EAAE,OAAO,CAAC;IACvB,oBAAoB,EAAE,OAAO,CAAC;IAC9B,uBAAuB,EAAE,OAAO,CAAC;IACjC,qBAAqB,EAAE,MAAM,CAAC;CACjC;AAED,+EAA+E;AAC/E,eAAO,MAAM,wBAAwB,uIAK3B,CAAC;AAEX,2EAA2E;AAC3E,eAAO,MAAM,uBAAuB,oGAK1B,CAAC;AAEX;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAC7B,MAAM,EAAE,kBAAkB,GAC3B,kBAAkB,CA2CpB"}
@@ -0,0 +1,59 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DEFAULT_NAME_ATTRIBUTES = exports.DEFAULT_EMAIL_ATTRIBUTES = void 0;
4
+ exports.resolveSamlConfig = resolveSamlConfig;
5
+ /** The attributes an email is read from, in order, when none is configured. */
6
+ exports.DEFAULT_EMAIL_ATTRIBUTES = [
7
+ 'email',
8
+ 'mail',
9
+ 'urn:oid:0.9.2342.19200300.100.1.3',
10
+ 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'
11
+ ];
12
+ /** The attributes a display name is read from, when none is configured. */
13
+ exports.DEFAULT_NAME_ATTRIBUTES = [
14
+ 'displayName',
15
+ 'cn',
16
+ 'name',
17
+ 'http://schemas.microsoft.com/identity/claims/displayname'
18
+ ];
19
+ /**
20
+ * Applies the defaults and rejects a configuration that cannot work.
21
+ *
22
+ * Eager, at construction, because every SSO failure looks identical by design:
23
+ * a missing certificate would otherwise reach an operator as the same blank
24
+ * "that sign-in did not complete" as a cancelled consent screen.
25
+ */
26
+ function resolveSamlConfig(config) {
27
+ let entry;
28
+ try {
29
+ entry = new URL(config.entryPoint);
30
+ }
31
+ catch {
32
+ throw new Error(`createSamlProvider needs an absolute entryPoint URL; got "${config.entryPoint}".`);
33
+ }
34
+ if (entry.protocol !== 'https:' && entry.hostname !== 'localhost') {
35
+ throw new Error(`createSamlProvider refuses the non-HTTPS entryPoint "${config.entryPoint}": the assertion would cross the network in clear text. Only localhost is exempt, for development.`);
36
+ }
37
+ if (!config.issuer.trim()) {
38
+ throw new Error('createSamlProvider needs an issuer — the entity id your identity provider has registered for this application.');
39
+ }
40
+ const certs = Array.isArray(config.idpCert)
41
+ ? config.idpCert
42
+ : [config.idpCert];
43
+ if (certs.length === 0 || certs.some((cert) => !cert?.trim())) {
44
+ throw new Error("createSamlProvider needs the identity provider's signing certificate. SAML has no discovery document and no key endpoint — the certificate is the whole of the trust relationship, so there is nothing to fall back to.");
45
+ }
46
+ if (config.privateKey && !config.signingCert) {
47
+ throw new Error('createSamlProvider was given a privateKey with no signingCert. An identity provider validates a signed AuthnRequest against the certificate you registered with it, so the pair has to travel together.');
48
+ }
49
+ return {
50
+ ...config,
51
+ label: config.label ?? 'SAML',
52
+ // Never defaulted to true. See the field's own note: there is no claim
53
+ // to read, so `true` can only ever be an operator's assertion.
54
+ emailVerified: config.emailVerified ?? false,
55
+ wantAssertionsSigned: config.wantAssertionsSigned ?? true,
56
+ wantAuthnResponseSigned: config.wantAuthnResponseSigned ?? true,
57
+ clockToleranceSeconds: config.clockToleranceSeconds ?? 60
58
+ };
59
+ }
@@ -0,0 +1,27 @@
1
+ import { type SsoProfile } from '@orthacms/identity-domain';
2
+ import { type ResolvedSamlConfig } from './config';
3
+ /** The parts of a validated SAML profile this adapter reads. */
4
+ export interface SamlAssertionProfile {
5
+ /** The `NameID`. */
6
+ nameID?: unknown;
7
+ /** Its format URI — `transient` is refused; see below. */
8
+ nameIDFormat?: unknown;
9
+ /** The IdP's session identifier, for back-channel logout. */
10
+ sessionIndex?: unknown;
11
+ /** Everything else the assertion carried, keyed by attribute name. */
12
+ attributes?: Record<string, unknown>;
13
+ /** node-saml also lifts some attributes onto the profile itself. */
14
+ [key: string]: unknown;
15
+ }
16
+ /**
17
+ * Turns a validated assertion into the normalised profile the CMS resolves
18
+ * accounts with.
19
+ *
20
+ * Separated from the adapter so it can be tested exhaustively without a signed
21
+ * assertion: signature validation belongs to `@node-saml/node-saml` and is not
22
+ * re-implemented here, but *which field means what* is this adapter's own
23
+ * decision and is where the interesting mistakes live. SAML's attribute names
24
+ * differ per identity provider more than anything else about it does.
25
+ */
26
+ export declare function toProfile(assertion: SamlAssertionProfile, config: ResolvedSamlConfig): SsoProfile;
27
+ //# sourceMappingURL=profile.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"profile.d.ts","sourceRoot":"","sources":["../../src/lib/profile.ts"],"names":[],"mappings":"AAAA,OAAO,EAAwB,KAAK,UAAU,EAAE,MAAM,2BAA2B,CAAC;AAClF,OAAO,EAGH,KAAK,kBAAkB,EAC1B,MAAM,UAAU,CAAC;AAElB,gEAAgE;AAChE,MAAM,WAAW,oBAAoB;IACjC,oBAAoB;IACpB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,0DAA0D;IAC1D,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,6DAA6D;IAC7D,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,sEAAsE;IACtE,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACrC,oEAAoE;IACpE,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CAC1B;AAMD;;;;;;;;;GASG;AACH,wBAAgB,SAAS,CACrB,SAAS,EAAE,oBAAoB,EAC/B,MAAM,EAAE,kBAAkB,GAC3B,UAAU,CAkCZ"}
@@ -0,0 +1,125 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.toProfile = toProfile;
4
+ const identity_domain_1 = require("@orthacms/identity-domain");
5
+ const config_1 = require("./config");
6
+ /** The `NameID` format that is explicitly *not* stable. */
7
+ const TRANSIENT_FORMAT = 'urn:oasis:names:tc:SAML:2.0:nameid-format:transient';
8
+ /**
9
+ * Turns a validated assertion into the normalised profile the CMS resolves
10
+ * accounts with.
11
+ *
12
+ * Separated from the adapter so it can be tested exhaustively without a signed
13
+ * assertion: signature validation belongs to `@node-saml/node-saml` and is not
14
+ * re-implemented here, but *which field means what* is this adapter's own
15
+ * decision and is where the interesting mistakes live. SAML's attribute names
16
+ * differ per identity provider more than anything else about it does.
17
+ */
18
+ function toProfile(assertion, config) {
19
+ const subject = readSubject(assertion, config);
20
+ const email = readEmail(assertion, config);
21
+ if (!email) {
22
+ throw new identity_domain_1.SsoVerificationError(`the assertion carried no email attribute. Configure emailAttribute to name the one your identity provider sends.`);
23
+ }
24
+ if (subject.toLowerCase() === email) {
25
+ // The core refuses this too, but naming the fix here is what turns a
26
+ // blank "sign-in did not complete" into something an operator can act
27
+ // on: the NameID format is `emailAddress`, and an address follows a
28
+ // person's mailbox rather than the person.
29
+ throw new identity_domain_1.SsoVerificationError('the NameID is the email address, which is not a stable identifier. Configure the identity provider to send a persistent NameID, or set subjectAttribute to an immutable directory id.');
30
+ }
31
+ return {
32
+ subject,
33
+ email,
34
+ // No claim exists to read — see `SamlProviderConfig.emailVerified`.
35
+ // This is the operator's assertion, reported faithfully as such.
36
+ emailVerified: config.emailVerified,
37
+ name: readName(assertion, config),
38
+ ...(config.groupsAttribute
39
+ ? { groups: readGroups(attribute(assertion, config.groupsAttribute)) }
40
+ : {}),
41
+ sessionId: typeof assertion.sessionIndex === 'string'
42
+ ? assertion.sessionIndex
43
+ : null
44
+ };
45
+ }
46
+ /** The stable identifier: a configured attribute, or the `NameID`. */
47
+ function readSubject(assertion, config) {
48
+ if (config.subjectAttribute) {
49
+ const value = attribute(assertion, config.subjectAttribute);
50
+ const subject = firstString([value]);
51
+ if (!subject) {
52
+ throw new identity_domain_1.SsoVerificationError(`the assertion carried no "${config.subjectAttribute}" attribute to key this person on`);
53
+ }
54
+ return subject;
55
+ }
56
+ if (assertion.nameIDFormat === TRANSIENT_FORMAT) {
57
+ // A transient NameID is a different value on every sign-in — that is
58
+ // its entire purpose. Keying a link on one would create a new link, and
59
+ // a new account under provisioning, every single time.
60
+ throw new identity_domain_1.SsoVerificationError('the identity provider sent a transient NameID, which is a different value on every sign-in. Configure a persistent NameID, or set subjectAttribute.');
61
+ }
62
+ const nameId = firstString([assertion.nameID]);
63
+ if (!nameId) {
64
+ throw new identity_domain_1.SsoVerificationError('the assertion carried no NameID and no subjectAttribute is configured');
65
+ }
66
+ return nameId;
67
+ }
68
+ /** The address, from the configured attribute or the usual spellings. */
69
+ function readEmail(assertion, config) {
70
+ const names = config.emailAttribute
71
+ ? [config.emailAttribute]
72
+ : config_1.DEFAULT_EMAIL_ATTRIBUTES;
73
+ const found = firstString(names.map((name) => attribute(assertion, name)));
74
+ if (found) {
75
+ return found.toLowerCase();
76
+ }
77
+ // Last resort: some identity providers put the address in the NameID and
78
+ // send no attribute at all. Only usable when it actually looks like one.
79
+ const nameId = firstString([assertion.nameID]);
80
+ return nameId?.includes('@') ? nameId.toLowerCase() : null;
81
+ }
82
+ /** A display name, or `null`. */
83
+ function readName(assertion, config) {
84
+ const names = config.nameAttribute
85
+ ? [config.nameAttribute]
86
+ : config_1.DEFAULT_NAME_ATTRIBUTES;
87
+ return firstString(names.map((name) => attribute(assertion, name)));
88
+ }
89
+ /**
90
+ * One attribute, looked for in both places node-saml puts them: the
91
+ * `attributes` bag, and lifted onto the profile itself.
92
+ */
93
+ function attribute(assertion, name) {
94
+ return assertion.attributes?.[name] ?? assertion[name];
95
+ }
96
+ /**
97
+ * Group membership, normalised.
98
+ *
99
+ * A SAML attribute with one value arrives as a string and with several as an
100
+ * array — the same attribute, two shapes, depending on how many groups the
101
+ * person happens to be in. Anything else is dropped rather than coerced: a
102
+ * group list nobody can read is safer empty than guessed at, because a
103
+ * role-mapping handler acts on it.
104
+ */
105
+ function readGroups(raw) {
106
+ if (Array.isArray(raw)) {
107
+ return raw.filter((item) => typeof item === 'string');
108
+ }
109
+ return typeof raw === 'string' && raw.trim() ? [raw.trim()] : [];
110
+ }
111
+ /** The first non-empty string among `values`, trimmed. */
112
+ function firstString(values) {
113
+ for (const value of values) {
114
+ if (typeof value === 'string' && value.trim()) {
115
+ return value.trim();
116
+ }
117
+ if (Array.isArray(value)) {
118
+ const first = value.find((item) => typeof item === 'string' && !!item.trim());
119
+ if (first) {
120
+ return first.trim();
121
+ }
122
+ }
123
+ }
124
+ return null;
125
+ }
@@ -0,0 +1,25 @@
1
+ import { type SsoProvider } from '@orthacms/identity-domain';
2
+ import { type SamlProviderConfig } from './config';
3
+ /**
4
+ * SAML 2.0 sign-in — HTTP-Redirect for the request, HTTP-POST for the response.
5
+ *
6
+ * **Its own package, and the reason `callbackMethod` is in the port's
7
+ * descriptor from the first release.** SAML's response is not a redirect: the
8
+ * identity provider returns the person by POSTing a form to the callback, so
9
+ * this adapter declares `'POST'` and the plugin mounts the route that serves
10
+ * it. Nothing about the seam had to change to add a second protocol shape,
11
+ * which was the whole bet.
12
+ *
13
+ * **`@node-saml/node-saml` does the XML.** Canonicalisation, signature
14
+ * validation, and the conditions checks are its job. That is a deliberate
15
+ * dependency: XML signature validation has a long history of wrapping attacks
16
+ * that turn on parser details, and it is not a place to demonstrate
17
+ * independence. ADR-0012 permits it here for exactly this reason and forbids it
18
+ * in `identity-domain` and `identity-server`.
19
+ *
20
+ * What *this* file owns is everything the library has no opinion about: which
21
+ * attribute means what, that a transient NameID cannot key a link, and that
22
+ * `RelayState` is where the core's `state` travels.
23
+ */
24
+ export declare function createSamlProvider(config: SamlProviderConfig): SsoProvider;
25
+ //# sourceMappingURL=saml-provider.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"saml-provider.d.ts","sourceRoot":"","sources":["../../src/lib/saml-provider.ts"],"names":[],"mappings":"AACA,OAAO,EAOH,KAAK,WAAW,EAEnB,MAAM,2BAA2B,CAAC;AACnC,OAAO,EAAqB,KAAK,kBAAkB,EAAE,MAAM,UAAU,CAAC;AAGtE;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,kBAAkB,GAAG,WAAW,CAgH1E"}
@@ -0,0 +1,117 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createSamlProvider = createSamlProvider;
4
+ const node_saml_1 = require("@node-saml/node-saml");
5
+ const identity_domain_1 = require("@orthacms/identity-domain");
6
+ const config_1 = require("./config");
7
+ const profile_1 = require("./profile");
8
+ /**
9
+ * SAML 2.0 sign-in — HTTP-Redirect for the request, HTTP-POST for the response.
10
+ *
11
+ * **Its own package, and the reason `callbackMethod` is in the port's
12
+ * descriptor from the first release.** SAML's response is not a redirect: the
13
+ * identity provider returns the person by POSTing a form to the callback, so
14
+ * this adapter declares `'POST'` and the plugin mounts the route that serves
15
+ * it. Nothing about the seam had to change to add a second protocol shape,
16
+ * which was the whole bet.
17
+ *
18
+ * **`@node-saml/node-saml` does the XML.** Canonicalisation, signature
19
+ * validation, and the conditions checks are its job. That is a deliberate
20
+ * dependency: XML signature validation has a long history of wrapping attacks
21
+ * that turn on parser details, and it is not a place to demonstrate
22
+ * independence. ADR-0012 permits it here for exactly this reason and forbids it
23
+ * in `identity-domain` and `identity-server`.
24
+ *
25
+ * What *this* file owns is everything the library has no opinion about: which
26
+ * attribute means what, that a transient NameID cannot key a link, and that
27
+ * `RelayState` is where the core's `state` travels.
28
+ */
29
+ function createSamlProvider(config) {
30
+ const resolved = (0, config_1.resolveSamlConfig)(config);
31
+ const descriptor = Object.freeze({
32
+ kind: 'saml',
33
+ label: resolved.label,
34
+ callbackMethod: 'POST'
35
+ });
36
+ /**
37
+ * One configured `SAML` instance per callback URL.
38
+ *
39
+ * The library takes the callback URL at construction while the port supplies
40
+ * it per request, and in practice there is exactly one — so this is a cache
41
+ * of size one that survives a deployment changing its public base URL
42
+ * without a restart, rather than rebuilding an XML validator on every
43
+ * sign-in.
44
+ */
45
+ const instances = new Map();
46
+ const samlFor = (callbackUrl) => {
47
+ const existing = instances.get(callbackUrl);
48
+ if (existing) {
49
+ return existing;
50
+ }
51
+ const saml = new node_saml_1.SAML({
52
+ callbackUrl,
53
+ entryPoint: resolved.entryPoint,
54
+ issuer: resolved.issuer,
55
+ idpCert: resolved.idpCert,
56
+ wantAssertionsSigned: resolved.wantAssertionsSigned,
57
+ wantAuthnResponseSigned: resolved.wantAuthnResponseSigned,
58
+ acceptedClockSkewMs: resolved.clockToleranceSeconds * 1000,
59
+ // The core already guarantees one-time use: the attempt row is
60
+ // burned before anything is exchanged. Asking the library to keep
61
+ // its own in-memory `InResponseTo` cache on top would add a second,
62
+ // per-process store that a multi-instance deployment gets wrong —
63
+ // and it would be the one that decides, since it runs first.
64
+ validateInResponseTo: node_saml_1.ValidateInResponseTo.never,
65
+ ...(resolved.privateKey ? { privateKey: resolved.privateKey } : {}),
66
+ ...(resolved.signingCert
67
+ ? { publicCert: resolved.signingCert }
68
+ : {})
69
+ });
70
+ instances.set(callbackUrl, saml);
71
+ return saml;
72
+ };
73
+ return {
74
+ descriptor: () => descriptor,
75
+ async authorize(request) {
76
+ // `RelayState` is SAML's spelling of `state`: an opaque value the
77
+ // identity provider echoes back untouched. The core mints it, and
78
+ // it is the only thing tying a response to an attempt — SAML has no
79
+ // nonce and no PKCE.
80
+ const url = await samlFor(request.redirectUri).getAuthorizeUrlAsync(request.state, undefined, {});
81
+ return { url };
82
+ },
83
+ async complete(callback) {
84
+ const { params } = callback;
85
+ if (params['RelayState'] !== callback.state) {
86
+ throw new identity_domain_1.SsoVerificationError('the echoed RelayState is not the one this attempt stored');
87
+ }
88
+ const response = params['SAMLResponse'];
89
+ if (!response) {
90
+ throw new identity_domain_1.SsoVerificationError('the form carried no SAMLResponse');
91
+ }
92
+ let profile;
93
+ try {
94
+ const validated = await samlFor(callback.redirectUri).validatePostResponseAsync({
95
+ SAMLResponse: response,
96
+ RelayState: params['RelayState']
97
+ });
98
+ profile = validated.profile;
99
+ }
100
+ catch (error) {
101
+ throw new identity_domain_1.SsoVerificationError(`the assertion did not validate (${error instanceof Error ? error.message : String(error)})`);
102
+ }
103
+ if (!profile) {
104
+ throw new identity_domain_1.SsoVerificationError('the response validated but carried no assertion');
105
+ }
106
+ return (0, profile_1.toProfile)(profile, resolved);
107
+ },
108
+ logoutUrl(_request) {
109
+ // Deliberately not implemented. SAML single logout is its own
110
+ // signed, bidirectional exchange — not a URL to redirect to — and
111
+ // pretending otherwise would send people to an endpoint that
112
+ // rejects them. Ending the CMS session still works; the identity
113
+ // provider's does not end with it, which is the honest answer.
114
+ return null;
115
+ }
116
+ };
117
+ }
@@ -0,0 +1,45 @@
1
+ import type { SsoAuthorizeRequest, SsoCallback } from '@orthacms/identity-domain';
2
+ export declare const ENTRY_POINT = "https://idp.test/sso";
3
+ export declare const SP_ISSUER = "https://cms.test/saml";
4
+ export declare const IDP_ISSUER = "https://idp.test";
5
+ export declare const CALLBACK = "https://cms.test/api/auth/sso/saml/callback";
6
+ /** The one-attempt secrets a core would have minted. */
7
+ export declare const CORE_SECRETS: SsoAuthorizeRequest;
8
+ /** A throwaway signing identity for the scripted identity provider. */
9
+ export interface SigningIdentity {
10
+ privateKey: string;
11
+ cert: string;
12
+ }
13
+ /**
14
+ * Generates a self-signed certificate for the scripted identity provider.
15
+ *
16
+ * A real key and a real certificate, because the assertions below are **really
17
+ * signed**: the only interesting thing about a SAML adapter's failure paths is
18
+ * that a response which does not verify is refused, and a fixture nobody signed
19
+ * could not show that.
20
+ */
21
+ export declare function signingIdentity(): Promise<SigningIdentity>;
22
+ /** What one scripted assertion says. */
23
+ export interface AssertionOptions {
24
+ nameId?: string;
25
+ nameIdFormat?: string;
26
+ sessionIndex?: string;
27
+ attributes?: Record<string, string | string[]>;
28
+ /** Skip the attribute statement entirely. */
29
+ omitAttributes?: boolean;
30
+ /** Backdate the assertion so its conditions have expired. */
31
+ expired?: boolean;
32
+ /** Sign with this identity instead of the one the adapter trusts. */
33
+ signWith?: SigningIdentity;
34
+ /**
35
+ * A non-success status — SAML's way of saying "the person did not
36
+ * authenticate". There is no `error` query parameter on this protocol; a
37
+ * refusal is a `StatusCode` inside a signed response.
38
+ */
39
+ status?: string;
40
+ }
41
+ /** Builds a signed `Response`, base64-encoded as the form field carries it. */
42
+ export declare function signedResponse(idp: SigningIdentity, options?: AssertionOptions): string;
43
+ /** The callback the core would build from an identity provider's form POST. */
44
+ export declare function callbackWith(overrides?: Record<string, string>): SsoCallback;
45
+ //# sourceMappingURL=test-support.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"test-support.d.ts","sourceRoot":"","sources":["../../src/lib/test-support.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,mBAAmB,EAAE,WAAW,EAAE,MAAM,2BAA2B,CAAC;AAElF,eAAO,MAAM,WAAW,yBAAyB,CAAC;AAClD,eAAO,MAAM,SAAS,0BAA0B,CAAC;AACjD,eAAO,MAAM,UAAU,qBAAqB,CAAC;AAC7C,eAAO,MAAM,QAAQ,gDAAgD,CAAC;AAEtE,wDAAwD;AACxD,eAAO,MAAM,YAAY,EAAE,mBAO1B,CAAC;AAEF,uEAAuE;AACvE,MAAM,WAAW,eAAe;IAC5B,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,EAAE,MAAM,CAAC;CAChB;AAED;;;;;;;GAOG;AACH,wBAAsB,eAAe,IAAI,OAAO,CAAC,eAAe,CAAC,CAKhE;AAED,wCAAwC;AACxC,MAAM,WAAW,gBAAgB;IAC7B,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC,CAAC;IAC/C,6CAA6C;IAC7C,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,6DAA6D;IAC7D,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,qEAAqE;IACrE,QAAQ,CAAC,EAAE,eAAe,CAAC;IAC3B;;;;OAIG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;CACnB;AAKD,+EAA+E;AAC/E,wBAAgB,cAAc,CAC1B,GAAG,EAAE,eAAe,EACpB,OAAO,GAAE,gBAAqB,GAC/B,MAAM,CAiFR;AAkCD,+EAA+E;AAC/E,wBAAgB,YAAY,CACxB,SAAS,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAM,GACvC,WAAW,CAWb"}
@@ -0,0 +1,137 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CORE_SECRETS = exports.CALLBACK = exports.IDP_ISSUER = exports.SP_ISSUER = exports.ENTRY_POINT = void 0;
4
+ exports.signingIdentity = signingIdentity;
5
+ exports.signedResponse = signedResponse;
6
+ exports.callbackWith = callbackWith;
7
+ const xml_crypto_1 = require("xml-crypto");
8
+ const selfsigned_1 = require("selfsigned");
9
+ exports.ENTRY_POINT = 'https://idp.test/sso';
10
+ exports.SP_ISSUER = 'https://cms.test/saml';
11
+ exports.IDP_ISSUER = 'https://idp.test';
12
+ exports.CALLBACK = 'https://cms.test/api/auth/sso/saml/callback';
13
+ /** The one-attempt secrets a core would have minted. */
14
+ exports.CORE_SECRETS = {
15
+ redirectUri: exports.CALLBACK,
16
+ state: 'state-2f6a1c9d',
17
+ // Present and unused: SAML has neither a nonce nor PKCE, and `RelayState`
18
+ // carries the whole of the replay defence.
19
+ nonce: 'nonce-8b0e47aa',
20
+ codeVerifier: 'verifier-4c1d55e0f39b2a7681ce'
21
+ };
22
+ /**
23
+ * Generates a self-signed certificate for the scripted identity provider.
24
+ *
25
+ * A real key and a real certificate, because the assertions below are **really
26
+ * signed**: the only interesting thing about a SAML adapter's failure paths is
27
+ * that a response which does not verify is refused, and a fixture nobody signed
28
+ * could not show that.
29
+ */
30
+ async function signingIdentity() {
31
+ const pems = await (0, selfsigned_1.generate)([{ name: 'commonName', value: 'idp.test' }], {
32
+ keySize: 2048
33
+ });
34
+ return { privateKey: pems.private, cert: pems.cert };
35
+ }
36
+ const PERSISTENT = 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent';
37
+ /** Builds a signed `Response`, base64-encoded as the form field carries it. */
38
+ function signedResponse(idp, options = {}) {
39
+ const now = Date.now();
40
+ const issued = new Date(options.expired ? now - 3_600_000 : now);
41
+ const expires = new Date(options.expired ? now - 3_540_000 : now + 300_000);
42
+ const iso = (date) => date.toISOString();
43
+ const attributes = options.omitAttributes
44
+ ? ''
45
+ : `<saml:AttributeStatement>${Object.entries(options.attributes ?? {
46
+ email: 'ada@example.com',
47
+ displayName: 'Ada Lovelace'
48
+ })
49
+ .map(([name, value]) => {
50
+ const values = Array.isArray(value) ? value : [value];
51
+ return `<saml:Attribute Name="${name}">${values
52
+ .map((item) => `<saml:AttributeValue>${item}</saml:AttributeValue>`)
53
+ .join('')}</saml:Attribute>`;
54
+ })
55
+ .join('')}</saml:AttributeStatement>`;
56
+ // A real refusal carries a status and **no assertion** — there is nobody to
57
+ // assert anything about. Building it any other way would test a shape no
58
+ // identity provider emits.
59
+ if (options.status && !options.status.endsWith(':Success')) {
60
+ const refusal = `<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" ` +
61
+ `xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ` +
62
+ `ID="_response1" Version="2.0" IssueInstant="${iso(issued)}" ` +
63
+ `Destination="${exports.CALLBACK}">` +
64
+ `<saml:Issuer>${exports.IDP_ISSUER}</saml:Issuer>` +
65
+ `<samlp:Status><samlp:StatusCode Value="${options.status}"/></samlp:Status>` +
66
+ `</samlp:Response>`;
67
+ return Buffer.from(sign(refusal, options.signWith ?? idp, 'Response'), 'utf8').toString('base64');
68
+ }
69
+ const xml = `<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" ` +
70
+ `xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ` +
71
+ `ID="_response1" Version="2.0" IssueInstant="${iso(issued)}" ` +
72
+ `Destination="${exports.CALLBACK}">` +
73
+ `<saml:Issuer>${exports.IDP_ISSUER}</saml:Issuer>` +
74
+ `<samlp:Status><samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/></samlp:Status>` +
75
+ `<saml:Assertion ID="_assertion1" Version="2.0" IssueInstant="${iso(issued)}">` +
76
+ `<saml:Issuer>${exports.IDP_ISSUER}</saml:Issuer>` +
77
+ `<saml:Subject>` +
78
+ `<saml:NameID Format="${options.nameIdFormat ?? PERSISTENT}">${options.nameId ?? 'idp-subject-1'}</saml:NameID>` +
79
+ `<saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">` +
80
+ `<saml:SubjectConfirmationData NotOnOrAfter="${iso(expires)}" Recipient="${exports.CALLBACK}"/>` +
81
+ `</saml:SubjectConfirmation>` +
82
+ `</saml:Subject>` +
83
+ `<saml:Conditions NotBefore="${iso(issued)}" NotOnOrAfter="${iso(expires)}">` +
84
+ `<saml:AudienceRestriction><saml:Audience>${exports.SP_ISSUER}</saml:Audience></saml:AudienceRestriction>` +
85
+ `</saml:Conditions>` +
86
+ `<saml:AuthnStatement AuthnInstant="${iso(issued)}" SessionIndex="${options.sessionIndex ?? 'idp-session-1'}">` +
87
+ `<saml:AuthnContext><saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport</saml:AuthnContextClassRef></saml:AuthnContext>` +
88
+ `</saml:AuthnStatement>` +
89
+ attributes +
90
+ `</saml:Assertion>` +
91
+ `</samlp:Response>`;
92
+ const signer = options.signWith ?? idp;
93
+ // Assertion first, then the whole response: an outer signature has to cover
94
+ // the inner one, so signing in the other order invalidates it immediately.
95
+ const signedAssertion = sign(xml, signer, 'Assertion');
96
+ const signed = sign(signedAssertion, signer, 'Response');
97
+ return Buffer.from(signed, 'utf8').toString('base64');
98
+ }
99
+ /** Adds an enveloped signature over one element, in place. */
100
+ function sign(xml, identity, element) {
101
+ const sig = new xml_crypto_1.SignedXml({
102
+ privateKey: identity.privateKey,
103
+ publicCert: identity.cert,
104
+ signatureAlgorithm: 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256',
105
+ canonicalizationAlgorithm: 'http://www.w3.org/2001/10/xml-exc-c14n#'
106
+ });
107
+ sig.addReference({
108
+ xpath: `//*[local-name(.)='${element}']`,
109
+ transforms: [
110
+ 'http://www.w3.org/2000/09/xmldsig#enveloped-signature',
111
+ 'http://www.w3.org/2001/10/xml-exc-c14n#'
112
+ ],
113
+ digestAlgorithm: 'http://www.w3.org/2001/04/xmlenc#sha256'
114
+ });
115
+ sig.computeSignature(xml, {
116
+ // Immediately after the element's own `Issuer`, which is where the
117
+ // schema puts a signature and where every identity provider emits it.
118
+ location: {
119
+ reference: `//*[local-name(.)='${element}']/*[local-name(.)='Issuer']`,
120
+ action: 'after'
121
+ }
122
+ });
123
+ return sig.getSignedXml();
124
+ }
125
+ /** The callback the core would build from an identity provider's form POST. */
126
+ function callbackWith(overrides = {}) {
127
+ return {
128
+ params: {
129
+ RelayState: exports.CORE_SECRETS.state,
130
+ ...overrides
131
+ },
132
+ state: exports.CORE_SECRETS.state,
133
+ nonce: exports.CORE_SECRETS.nonce,
134
+ codeVerifier: exports.CORE_SECRETS.codeVerifier,
135
+ redirectUri: exports.CALLBACK
136
+ };
137
+ }
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@orthacms/identity-provider-saml",
3
+ "version": "0.4.0",
4
+ "description": "@orthacms/identity-provider-saml — part of Ortha CMS.",
5
+ "license": "MIT",
6
+ "homepage": "https://github.com/ortha-source/ortha-cms/tree/main/packages/identity/provider-saml",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/ortha-source/ortha-cms.git",
10
+ "directory": "packages/identity/provider-saml"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/ortha-source/ortha-cms/issues"
14
+ },
15
+ "main": "./dist/index.js",
16
+ "types": "./dist/index.d.ts",
17
+ "exports": {
18
+ ".": {
19
+ "types": "./dist/index.d.ts",
20
+ "default": "./dist/index.js"
21
+ },
22
+ "./package.json": "./package.json"
23
+ },
24
+ "files": [
25
+ "dist"
26
+ ],
27
+ "dependencies": {
28
+ "@node-saml/node-saml": "^5.1.0",
29
+ "@orthacms/identity-domain": "^0.4.0",
30
+ "selfsigned": "^5.5.0",
31
+ "tslib": "^2.3.0",
32
+ "xml-crypto": "^6.1.2"
33
+ },
34
+ "publishConfig": {
35
+ "access": "public"
36
+ }
37
+ }