diff --git a/README.md b/README.md index 13490431..62f28434 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Passport-SAML has been tested to work with Onelogin, Okta, Shibboleth, [SimpleSA ## Installation $ npm install passport-saml - +/ ## Usage The examples utilize the [Feide OpenIdp identity provider](https://openidp.feide.no/). You need an account there to log in with this. You also need to [register your site](https://openidp.feide.no/simplesaml/module.php/metaedit/index.php) as a service provider. @@ -134,6 +134,23 @@ type Profile = { * `skipRequestCompression`: if set to true, the SAML request from the service provider won't be compressed. * `authnRequestBinding`: if set to `HTTP-POST`, will request authentication from IDP via HTTP POST binding, otherwise defaults to HTTP Redirect * `disableRequestACSUrl`: if truthy, SAML AuthnRequest from the service provider will not include the optional AssertionConsumerServiceURL. Default is falsy so it is automatically included. + * `scoping`: An optional configuration which implements the functionality [explained in the SAML spec paragraph "3.4.1.2 Element "](https://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf). The config object is structured as following: +```javascript +{ + idpList: { // optional + entries: [ // required + { + providerId: 'yourProviderId', // required for each entry + name: 'yourName', // optional + loc: 'yourLoc', // optional + } + ], + getComplete: 'URI to your complete IDP list', // optional + }, + proxyCount: 2, // optional + requesterId: 'requesterId', // optional +} +``` * **InResponseTo Validation** * `validateInResponseTo`: if truthy, then InResponseTo will be validated from incoming SAML responses * `requestIdExpirationPeriodMs`: Defines the expiration time when a Request ID generated for a SAML request will not be valid if seen in a SAML response in the `InResponseTo` field. Default is 8 hours. diff --git a/src/passport-saml/saml.ts b/src/passport-saml/saml.ts index e0ed1138..369236ac 100644 --- a/src/passport-saml/saml.ts +++ b/src/passport-saml/saml.ts @@ -14,6 +14,7 @@ import {CacheProvider as InMemoryCacheProvider} from './inmemory-cache-provider' import * as algorithms from './algorithms'; import { signAuthnRequestPost } from './saml-post-signing'; import type { Request } from 'express'; +import { SamlIDPEntryConfig, SamlIDPListConfig } from './types'; function processValidlySignedPostRequest(self: SAML, doc, dom, callback) { const request = doc.LogoutRequest; @@ -305,6 +306,56 @@ class SAML { request['samlp:AuthnRequest']['@ProviderName'] = this.options.providerName; } + if (this.options.scoping) { + const scoping = { + '@xmlns:samlp': 'urn:oasis:names:tc:SAML:2.0:protocol', + }; + + if (typeof this.options.scoping.proxyCount === 'number') { + scoping['@ProxyCount'] = this.options.scoping.proxyCount; + } + + if (this.options.scoping.idpList) { + scoping['samlp:IDPList'] = this.options.scoping.idpList.map((idpListItem: SamlIDPListConfig) => { + const formattedIdpListItem = { + '@xmlns:samlp': 'urn:oasis:names:tc:SAML:2.0:protocol', + }; + + if (idpListItem.entries) { + formattedIdpListItem['samlp:IDPEntry'] = idpListItem.entries.map((entry: SamlIDPEntryConfig) => { + const formattedEntry = { + '@xmlns:samlp': 'urn:oasis:names:tc:SAML:2.0:protocol', + }; + + formattedEntry['@ProviderID'] = entry.providerId; + + if (entry.name) { + formattedEntry['@Name'] = entry.name; + } + + if (entry.loc) { + formattedEntry['@Loc'] = entry.loc; + } + + return formattedEntry; + }); + } + + if (idpListItem.getComplete) { + formattedIdpListItem['samlp:GetComplete'] = idpListItem.getComplete; + } + + return formattedIdpListItem; + }); + } + + if (this.options.scoping.requesterId) { + scoping['samlp:RequesterID'] = this.options.scoping.requesterId; + } + + request['samlp:AuthnRequest']['samlp:Scoping'] = scoping; + } + let stringRequest = xmlbuilder.create(request).end(); if (isHttpPostBinding && this.options.privateCert) { stringRequest = signAuthnRequestPost(stringRequest, this.options); diff --git a/src/passport-saml/types.ts b/src/passport-saml/types.ts index 944e0161..c413b5ef 100644 --- a/src/passport-saml/types.ts +++ b/src/passport-saml/types.ts @@ -41,6 +41,7 @@ export interface SamlConfig { passive?: boolean; idpIssuer?: string; audience?: string; + scoping? : SamlScopingConfig; // InResponseTo Validation validateInResponseTo?: boolean; @@ -57,6 +58,23 @@ export interface SamlConfig { logoutCallbackUrl?: string; } +export interface SamlScopingConfig { + idpList: SamlIDPListConfig[]; + proxyCount?: number; + requesterId?: string[]; +} + +export interface SamlIDPListConfig { + entries: SamlIDPEntryConfig[]; + getComplete?: string; +} + +export interface SamlIDPEntryConfig { + providerId: string; + name?: string; + loc?: string; +} + export type Profile = { issuer?: string; sessionIndex?: string; diff --git a/test/tests.js b/test/tests.js index ab186051..b33d58ca 100644 --- a/test/tests.js +++ b/test/tests.js @@ -524,7 +524,457 @@ describe( 'passport-saml /', function() { 'saml:Issuer': [ { _: 'onelogin_saml', '$': { 'xmlns:saml': 'urn:oasis:names:tc:SAML:2.0:assertion' } } ] } } - } + }, + { name: "Config with full Scoping config", + config: { + issuer: 'http://exampleSp.com/saml', + identifierFormat: 'alternateIdentifier', + scoping: { + proxyCount: 2, + requesterId: 'fooBarRequesterId', + idpList: [ + { + entries: [ + { + providerId: 'myScopingProviderId', + name: 'myScopingProviderName', + loc: 'myScopingProviderLoc', + }, + ], + getComplete: 'https://www.getcompleteidplist.com', + }, + ], + }, + }, + result: { + 'samlp:AuthnRequest': { + '$': { + AssertionConsumerServiceURL: 'http://localhost:3033/login', + Destination: 'https://wwwexampleIdp.com/saml', + ProtocolBinding: 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST', + Version: '2.0', + 'xmlns:samlp': 'urn:oasis:names:tc:SAML:2.0:protocol' + }, + 'saml:Issuer': [ + { + '$': { + 'xmlns:saml': 'urn:oasis:names:tc:SAML:2.0:assertion', + }, + '_': 'http://exampleSp.com/saml', + } + ], + 'samlp:NameIDPolicy': [ + { + '$': { + AllowCreate: 'true', + Format: 'alternateIdentifier', + 'xmlns:samlp': 'urn:oasis:names:tc:SAML:2.0:protocol', + } + } + ], + 'samlp:RequestedAuthnContext': [ + { + '$': { + Comparison: 'exact', + 'xmlns:samlp': 'urn:oasis:names:tc:SAML:2.0:protocol' + }, + 'saml:AuthnContextClassRef': [ + { + '$': { + 'xmlns:saml': 'urn:oasis:names:tc:SAML:2.0:assertion' + }, + '_': 'urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport' + } + ] + } + ], + 'samlp:Scoping': [ + { + '$': { + 'xmlns:samlp': 'urn:oasis:names:tc:SAML:2.0:protocol', + ProxyCount: '2', + }, + 'samlp:IDPList': [ + { + '$': { + 'xmlns:samlp': 'urn:oasis:names:tc:SAML:2.0:protocol' + }, + 'samlp:GetComplete': [ + 'https://www.getcompleteidplist.com' + ], + 'samlp:IDPEntry': [ + { + '$': { + Loc: 'myScopingProviderLoc', + Name: 'myScopingProviderName', + ProviderID: 'myScopingProviderId', + 'xmlns:samlp': 'urn:oasis:names:tc:SAML:2.0:protocol' + } + } + ] + } + ], + 'samlp:RequesterID': [ + 'fooBarRequesterId' + ] + } + ] + } + } }, + { name: "Config with Scoping config without proxyCount and requesterId", + config: { + issuer: 'http://exampleSp.com/saml', + identifierFormat: 'alternateIdentifier', + scoping: { + idpList: [ + { + entries: [ + { + providerId: 'myScopingProviderId', + name: 'myScopingProviderName', + loc: 'myScopingProviderLoc', + }, + ], + getComplete: 'https://www.getcompleteidplist.com', + }, + ], + }, + }, + result: { + 'samlp:AuthnRequest': { + '$': { + AssertionConsumerServiceURL: 'http://localhost:3033/login', + Destination: 'https://wwwexampleIdp.com/saml', + ProtocolBinding: 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST', + Version: '2.0', + 'xmlns:samlp': 'urn:oasis:names:tc:SAML:2.0:protocol' + }, + 'saml:Issuer': [ + { + '$': { + 'xmlns:saml': 'urn:oasis:names:tc:SAML:2.0:assertion', + }, + '_': 'http://exampleSp.com/saml', + } + ], + 'samlp:NameIDPolicy': [ + { + '$': { + AllowCreate: 'true', + Format: 'alternateIdentifier', + 'xmlns:samlp': 'urn:oasis:names:tc:SAML:2.0:protocol', + } + } + ], + 'samlp:RequestedAuthnContext': [ + { + '$': { + Comparison: 'exact', + 'xmlns:samlp': 'urn:oasis:names:tc:SAML:2.0:protocol' + }, + 'saml:AuthnContextClassRef': [ + { + '$': { + 'xmlns:saml': 'urn:oasis:names:tc:SAML:2.0:assertion' + }, + '_': 'urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport' + } + ] + } + ], + 'samlp:Scoping': [ + { + '$': { + 'xmlns:samlp': 'urn:oasis:names:tc:SAML:2.0:protocol', + }, + 'samlp:IDPList': [ + { + '$': { + 'xmlns:samlp': 'urn:oasis:names:tc:SAML:2.0:protocol' + }, + 'samlp:GetComplete': [ + 'https://www.getcompleteidplist.com' + ], + 'samlp:IDPEntry': [ + { + '$': { + Loc: 'myScopingProviderLoc', + Name: 'myScopingProviderName', + ProviderID: 'myScopingProviderId', + 'xmlns:samlp': 'urn:oasis:names:tc:SAML:2.0:protocol' + } + } + ] + } + ] + } + ] + } + } }, + { name: "Config with Scoping config without proxyCount, requesterId, getComplete", + config: { + issuer: 'http://exampleSp.com/saml', + identifierFormat: 'alternateIdentifier', + scoping: { + idpList: [ + { + entries: [ + { + providerId: 'myScopingProviderId', + name: 'myScopingProviderName', + loc: 'myScopingProviderLoc', + }, + ] + }, + ], + }, + }, + result: { + 'samlp:AuthnRequest': { + '$': { + AssertionConsumerServiceURL: 'http://localhost:3033/login', + Destination: 'https://wwwexampleIdp.com/saml', + ProtocolBinding: 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST', + Version: '2.0', + 'xmlns:samlp': 'urn:oasis:names:tc:SAML:2.0:protocol' + }, + 'saml:Issuer': [ + { + '$': { + 'xmlns:saml': 'urn:oasis:names:tc:SAML:2.0:assertion', + }, + '_': 'http://exampleSp.com/saml', + } + ], + 'samlp:NameIDPolicy': [ + { + '$': { + AllowCreate: 'true', + Format: 'alternateIdentifier', + 'xmlns:samlp': 'urn:oasis:names:tc:SAML:2.0:protocol', + } + } + ], + 'samlp:RequestedAuthnContext': [ + { + '$': { + Comparison: 'exact', + 'xmlns:samlp': 'urn:oasis:names:tc:SAML:2.0:protocol' + }, + 'saml:AuthnContextClassRef': [ + { + '$': { + 'xmlns:saml': 'urn:oasis:names:tc:SAML:2.0:assertion' + }, + '_': 'urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport' + } + ] + } + ], + 'samlp:Scoping': [ + { + '$': { + 'xmlns:samlp': 'urn:oasis:names:tc:SAML:2.0:protocol', + }, + 'samlp:IDPList': [ + { + '$': { + 'xmlns:samlp': 'urn:oasis:names:tc:SAML:2.0:protocol' + }, + 'samlp:IDPEntry': [ + { + '$': { + Loc: 'myScopingProviderLoc', + Name: 'myScopingProviderName', + ProviderID: 'myScopingProviderId', + 'xmlns:samlp': 'urn:oasis:names:tc:SAML:2.0:protocol' + } + } + ] + } + ] + } + ] + } + } }, + { name: "Config with Scoping config without proxyCount, requesterId, idpList getComplete, entry name, entry loc", + config: { + issuer: 'http://exampleSp.com/saml', + identifierFormat: 'alternateIdentifier', + scoping: { + idpList: [ + { + entries: [ + { + providerId: 'myScopingProviderId' + }, + ] + }, + ], + }, + }, + result: { + 'samlp:AuthnRequest': { + '$': { + AssertionConsumerServiceURL: 'http://localhost:3033/login', + Destination: 'https://wwwexampleIdp.com/saml', + ProtocolBinding: 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST', + Version: '2.0', + 'xmlns:samlp': 'urn:oasis:names:tc:SAML:2.0:protocol' + }, + 'saml:Issuer': [ + { + '$': { + 'xmlns:saml': 'urn:oasis:names:tc:SAML:2.0:assertion', + }, + '_': 'http://exampleSp.com/saml', + } + ], + 'samlp:NameIDPolicy': [ + { + '$': { + AllowCreate: 'true', + Format: 'alternateIdentifier', + 'xmlns:samlp': 'urn:oasis:names:tc:SAML:2.0:protocol', + } + } + ], + 'samlp:RequestedAuthnContext': [ + { + '$': { + Comparison: 'exact', + 'xmlns:samlp': 'urn:oasis:names:tc:SAML:2.0:protocol' + }, + 'saml:AuthnContextClassRef': [ + { + '$': { + 'xmlns:saml': 'urn:oasis:names:tc:SAML:2.0:assertion' + }, + '_': 'urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport' + } + ] + } + ], + 'samlp:Scoping': [ + { + '$': { + 'xmlns:samlp': 'urn:oasis:names:tc:SAML:2.0:protocol', + }, + 'samlp:IDPList': [ + { + '$': { + 'xmlns:samlp': 'urn:oasis:names:tc:SAML:2.0:protocol' + }, + 'samlp:IDPEntry': [ + { + '$': { + ProviderID: 'myScopingProviderId', + 'xmlns:samlp': 'urn:oasis:names:tc:SAML:2.0:protocol' + } + } + ] + } + ] + } + ] + } + } }, + { name: "Config with Scoping and multiple IDPList entries", + config: { + issuer: 'http://exampleSp.com/saml', + identifierFormat: 'alternateIdentifier', + scoping: { + idpList: [ + { + entries: [ + { + providerId: 'myScopingProviderId' + }, + { + providerId: 'myOtherScopingProviderId' + }, + ] + }, + ], + }, + }, + result: { + 'samlp:AuthnRequest': { + '$': { + AssertionConsumerServiceURL: 'http://localhost:3033/login', + Destination: 'https://wwwexampleIdp.com/saml', + ProtocolBinding: 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST', + Version: '2.0', + 'xmlns:samlp': 'urn:oasis:names:tc:SAML:2.0:protocol' + }, + 'saml:Issuer': [ + { + '$': { + 'xmlns:saml': 'urn:oasis:names:tc:SAML:2.0:assertion', + }, + '_': 'http://exampleSp.com/saml', + } + ], + 'samlp:NameIDPolicy': [ + { + '$': { + AllowCreate: 'true', + Format: 'alternateIdentifier', + 'xmlns:samlp': 'urn:oasis:names:tc:SAML:2.0:protocol', + } + } + ], + 'samlp:RequestedAuthnContext': [ + { + '$': { + Comparison: 'exact', + 'xmlns:samlp': 'urn:oasis:names:tc:SAML:2.0:protocol' + }, + 'saml:AuthnContextClassRef': [ + { + '$': { + 'xmlns:saml': 'urn:oasis:names:tc:SAML:2.0:assertion' + }, + '_': 'urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport' + } + ] + } + ], + 'samlp:Scoping': [ + { + '$': { + 'xmlns:samlp': 'urn:oasis:names:tc:SAML:2.0:protocol', + }, + 'samlp:IDPList': [ + { + '$': { + 'xmlns:samlp': 'urn:oasis:names:tc:SAML:2.0:protocol' + }, + 'samlp:IDPEntry': [ + { + '$': { + ProviderID: 'myScopingProviderId', + 'xmlns:samlp': 'urn:oasis:names:tc:SAML:2.0:protocol' + } + }, + { + '$': { + ProviderID: 'myOtherScopingProviderId', + 'xmlns:samlp': 'urn:oasis:names:tc:SAML:2.0:protocol' + } + } + ] + }, + ] + } + ] + } + } } + + + + + ]; var server; @@ -1354,7 +1804,7 @@ describe( 'passport-saml /', function() { const nameQualifier = 'https://idp.example.org/idp/saml' const spNameQualifier = 'https://sp.example.org/sp/entity' const format = 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent' - const xml = + const xml = '' + '' + '' + @@ -1385,7 +1835,7 @@ describe( 'passport-saml /', function() { }); it( 'An undefined value given with an object should still be undefined', function( done ) { - const xml = + const xml = '' + '' + '' +