@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 +21 -0
- package/README.md +7 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/lib/config.d.ts +85 -0
- package/dist/lib/config.d.ts.map +1 -0
- package/dist/lib/config.js +59 -0
- package/dist/lib/profile.d.ts +27 -0
- package/dist/lib/profile.d.ts.map +1 -0
- package/dist/lib/profile.js +125 -0
- package/dist/lib/saml-provider.d.ts +25 -0
- package/dist/lib/saml-provider.d.ts.map +1 -0
- package/dist/lib/saml-provider.js +117 -0
- package/dist/lib/test-support.d.ts +45 -0
- package/dist/lib/test-support.d.ts.map +1 -0
- package/dist/lib/test-support.js +137 -0
- package/package.json +37 -0
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
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|