diff --git a/index.ts b/index.ts index 7cdb5393..0e0e6107 100644 --- a/index.ts +++ b/index.ts @@ -14,6 +14,9 @@ import * as Extractor from './src/extractor'; // exposed methods for customizing samlify import { setSchemaValidator, setDOMParserOptions } from './src/api'; +// export types +export type { LoginRequestOptions } from './src/types'; + export { Constants, Extractor, diff --git a/src/binding-post.ts b/src/binding-post.ts index 06d333aa..193f62cc 100644 --- a/src/binding-post.ts +++ b/src/binding-post.ts @@ -8,16 +8,27 @@ import { wording, namespace, StatusCode } from './urn'; import { BindingContext } from './entity'; import libsaml from './libsaml'; import utility, { get } from './utility'; +import { LoginRequestOptions } from './types'; const binding = wording.binding; /** * @desc Generate a base64 encoded login request -* @param {string} referenceTagXPath reference uri -* @param {object} entity object includes both idp and sp -* @param {function} customTagReplacement used when developers have their own login response template +* @param {string} referenceTagXPath reference uri +* @param {object} entity object includes both idp and sp +* @param {function | LoginRequestOptions} options options for this specific request */ -function base64LoginRequest(referenceTagXPath: string, entity: any, customTagReplacement?: (template: string) => BindingContext): BindingContext { +function base64LoginRequest(referenceTagXPath: string, entity: any, options?: ((template: string) => BindingContext) | LoginRequestOptions): BindingContext { + let customTagReplacement: ((template: string) => BindingContext) | undefined; + let requestOptions: LoginRequestOptions | undefined; + + if (typeof options === 'function') { + customTagReplacement = options; + } else if (options) { + requestOptions = options; + customTagReplacement = options.customTagReplacement; + } + const metadata = { idp: entity.idp.entityMeta, sp: entity.sp.entityMeta }; const spSetting = entity.sp.entitySetting; let id: string = ''; @@ -41,6 +52,7 @@ function base64LoginRequest(referenceTagXPath: string, entity: any, customTagRep AssertionConsumerServiceURL: metadata.sp.getAssertionConsumerService(binding.post), EntityID: metadata.sp.getEntityID(), AllowCreate: spSetting.allowCreate, + ForceAuthn: requestOptions?.forceAuthn ?? false, NameIDFormat: selectedNameIDFormat } as any); } diff --git a/src/binding-redirect.ts b/src/binding-redirect.ts index 4ff402d9..0d8ae7e6 100644 --- a/src/binding-redirect.ts +++ b/src/binding-redirect.ts @@ -10,6 +10,7 @@ import { IdentityProvider as Idp } from './entity-idp'; import { ServiceProvider as Sp } from './entity-sp'; import * as url from 'url'; import { wording, namespace } from './urn'; +import { LoginRequestOptions } from './types'; const binding = wording.binding; const urlParams = wording.urlParams; @@ -79,11 +80,20 @@ function buildRedirectURL(opts: BuildRedirectConfig) { } /** * @desc Redirect URL for login request -* @param {object} entity object includes both idp and sp -* @param {function} customTagReplacement used when developers have their own login response template +* @param {object} entity object includes both idp and sp +* @param {function | LoginRequestOptions} options options for this specific request * @return {string} redirect URL */ -function loginRequestRedirectURL(entity: { idp: Idp, sp: Sp }, customTagReplacement?: (template: string) => BindingContext): BindingContext { +function loginRequestRedirectURL(entity: { idp: Idp, sp: Sp }, options?: ((template: string) => BindingContext) | LoginRequestOptions): BindingContext { + let customTagReplacement: ((template: string) => BindingContext) | undefined; + let requestOptions: LoginRequestOptions | undefined; + + if (typeof options === 'function') { + customTagReplacement = options; + } else if (options) { + requestOptions = options; + customTagReplacement = options.customTagReplacement; + } const metadata: any = { idp: entity.idp.entityMeta, sp: entity.sp.entityMeta }; const spSetting: any = entity.sp.entitySetting; @@ -109,6 +119,7 @@ function loginRequestRedirectURL(entity: { idp: Idp, sp: Sp }, customTagReplacem AssertionConsumerServiceURL: metadata.sp.getAssertionConsumerService(binding.post), EntityID: metadata.sp.getEntityID(), AllowCreate: spSetting.allowCreate, + ForceAuthn: requestOptions?.forceAuthn ?? false, } as any); } return { diff --git a/src/binding-simplesign.ts b/src/binding-simplesign.ts index e40cc0d1..3bd1b371 100644 --- a/src/binding-simplesign.ts +++ b/src/binding-simplesign.ts @@ -8,6 +8,7 @@ import { wording, StatusCode } from './urn'; import { BindingContext, SimpleSignComputedContext } from './entity'; import libsaml from './libsaml'; import utility, { get } from './utility'; +import { LoginRequestOptions } from './types'; const binding = wording.binding; const urlParams = wording.urlParams; @@ -71,11 +72,20 @@ function buildSimpleSignature(opts: BuildSimpleSignConfig) : string { /** * @desc Generate a base64 encoded login request -* @param {string} referenceTagXPath reference uri -* @param {object} entity object includes both idp and sp -* @param {function} customTagReplacement used when developers have their own login response template +* @param {object} entity object includes both idp and sp +* @param {function | LoginRequestOptions} options options for this specific request */ -function base64LoginRequest(entity: any, customTagReplacement?: (template: string) => BindingContext): SimpleSignComputedContext { +function base64LoginRequest(entity: any, options?: ((template: string) => BindingContext) | LoginRequestOptions): SimpleSignComputedContext { + let customTagReplacement: ((template: string) => BindingContext) | undefined; + let requestOptions: LoginRequestOptions | undefined; + + if (typeof options === 'function') { + customTagReplacement = options; + } else if (options) { + requestOptions = options; + customTagReplacement = options.customTagReplacement; + } + const metadata = { idp: entity.idp.entityMeta, sp: entity.sp.entityMeta }; const spSetting = entity.sp.entitySetting; let id: string = ''; @@ -99,6 +109,7 @@ function base64LoginRequest(entity: any, customTagReplacement?: (template: strin AssertionConsumerServiceURL: metadata.sp.getAssertionConsumerService(binding.simpleSign), EntityID: metadata.sp.getEntityID(), AllowCreate: spSetting.allowCreate, + ForceAuthn: requestOptions?.forceAuthn ?? false, NameIDFormat: selectedNameIDFormat } as any); } diff --git a/src/entity-sp.ts b/src/entity-sp.ts index 454d5664..64ffbfad 100644 --- a/src/entity-sp.ts +++ b/src/entity-sp.ts @@ -13,6 +13,7 @@ import { IdentityProviderConstructor as IdentityProvider, ServiceProviderMetadata, ServiceProviderSettings, + LoginRequestOptions, } from './types'; import { namespace } from './urn'; import redirectBinding from './binding-redirect'; @@ -50,14 +51,14 @@ export class ServiceProvider extends Entity { /** * @desc Generates the login request for developers to design their own method - * @param {IdentityProvider} idp object of identity provider - * @param {string} binding protocol binding - * @param {function} customTagReplacement used when developers have their own login response template + * @param {IdentityProvider} idp object of identity provider + * @param {string} binding protocol binding + * @param {function | LoginRequestOptions} options options for this specific request */ public createLoginRequest( idp: IdentityProvider, binding = 'redirect', - customTagReplacement?: (template: string) => BindingContext, + options?: ((template: string) => BindingContext) | LoginRequestOptions, ): BindingContext | PostBindingContext| SimpleSignBindingContext { const nsBinding = namespace.binding; const protocol = nsBinding[binding]; @@ -68,21 +69,21 @@ export class ServiceProvider extends Entity { let context: any = null; switch (protocol) { case nsBinding.redirect: - return redirectBinding.loginRequestRedirectURL({ idp, sp: this }, customTagReplacement); + return redirectBinding.loginRequestRedirectURL({ idp, sp: this }, options); case nsBinding.post: - context = postBinding.base64LoginRequest("/*[local-name(.)='AuthnRequest']", { idp, sp: this }, customTagReplacement); + context = postBinding.base64LoginRequest("/*[local-name(.)='AuthnRequest']", { idp, sp: this }, options); break; case nsBinding.simpleSign: // Object context = {id, context, signature, sigAlg} - context = simpleSignBinding.base64LoginRequest( { idp, sp: this }, customTagReplacement); + context = simpleSignBinding.base64LoginRequest({ idp, sp: this }, options); break; default: // Will support artifact in the next release throw new Error('ERR_SP_LOGIN_REQUEST_UNDEFINED_BINDING'); - } + } return { ...context, diff --git a/src/extractor.ts b/src/extractor.ts index 2cbf8c24..740f8c5c 100644 --- a/src/extractor.ts +++ b/src/extractor.ts @@ -44,7 +44,7 @@ export const loginRequestFields: ExtractorFields = [ { key: 'request', localPath: ['AuthnRequest'], - attributes: ['ID', 'IssueInstant', 'Destination', 'AssertionConsumerServiceURL'] + attributes: ['ID', 'IssueInstant', 'Destination', 'AssertionConsumerServiceURL', 'ForceAuthn'] }, { key: 'issuer', diff --git a/src/libsaml.ts b/src/libsaml.ts index bdb48356..8eb3bba4 100644 --- a/src/libsaml.ts +++ b/src/libsaml.ts @@ -143,7 +143,7 @@ const libSaml = () => { * @type {LoginRequestTemplate} */ const defaultLoginRequestTemplate = { - context: '{Issuer}', + context: '{Issuer}', }; /** * @desc Default logout request template diff --git a/src/types.ts b/src/types.ts index af8d3414..ca048bdc 100644 --- a/src/types.ts +++ b/src/types.ts @@ -92,6 +92,13 @@ export type ServiceProviderSettings = { clockDrifts?: [number, number]; }; +export type LoginRequestOptions = { + /** Force re-authentication at the IdP */ + forceAuthn?: boolean; + /** Custom tag replacement for login request template */ + customTagReplacement?: (template: string) => any; +}; + export type IdentityProviderSettings = { metadata?: string | Buffer; diff --git a/src/urn.ts b/src/urn.ts index df71255a..f22b2465 100644 --- a/src/urn.ts +++ b/src/urn.ts @@ -104,6 +104,7 @@ const namespace = { const tags = { request: { AllowCreate: '{AllowCreate}', + ForceAuthn: '{ForceAuthn}', AssertionConsumerServiceURL: '{AssertionConsumerServiceURL}', AuthnContextClassRef: '{AuthnContextClassRef}', AssertionID: '{AssertionID}', diff --git a/test/flow.ts b/test/flow.ts index 3764fa97..5336a648 100644 --- a/test/flow.ts +++ b/test/flow.ts @@ -207,6 +207,50 @@ test('create login request with post binding using default template and parse it expect(typeof extract.signature).toBe('string'); }); +test('create login request with redirect binding and per-request forceAuthn=true', async () => { + const { id, context } = sp.createLoginRequest(idp, 'redirect', { forceAuthn: true }); + expect(typeof id).toBe('string'); + expect(typeof context).toBe('string'); + const originalURL = url.parse(context, true); + const SAMLRequest = originalURL.query.SAMLRequest; + const Signature = originalURL.query.Signature; + const SigAlg = originalURL.query.SigAlg; + delete originalURL.query.Signature; + const octetString = Object.keys(originalURL.query).map(q => q + '=' + encodeURIComponent(originalURL.query[q] as string)).join('&'); + const { extract } = await idp.parseLoginRequest(sp, 'redirect', { query: { SAMLRequest, Signature, SigAlg }, octetString}); + expect(extract.request.forceAuthn).toBe('true'); +}); + +test('create login request with post binding and per-request forceAuthn=true', async () => { + const { id, context: SAMLRequest } = sp.createLoginRequest(idp, 'post', { forceAuthn: true }) as PostBindingContext; + expect(typeof id).toBe('string'); + expect(typeof SAMLRequest).toBe('string'); + const { extract } = await idp.parseLoginRequest(sp, 'post', { body: { SAMLRequest }}); + expect(extract.request.forceAuthn).toBe('true'); +}); + +test('create login request with simpleSign binding and per-request forceAuthn=true', async () => { + const { id, context: SAMLRequest, type, sigAlg, signature, relayState } = sp.createLoginRequest(idp, 'simpleSign', { forceAuthn: true }) as SimpleSignBindingContext; + expect(typeof id).toBe('string'); + expect(typeof SAMLRequest).toBe('string'); + const octetString = buildSimpleSignOctetString(type, SAMLRequest, sigAlg, relayState, signature); + const { extract } = await idp.parseLoginRequest(sp, 'simpleSign', { body: { SAMLRequest, Signature: signature, SigAlg: sigAlg }, octetString}); + expect(extract.request.forceAuthn).toBe('true'); +}); + +test('create login request with default forceAuthn=false', async () => { + const { id, context } = sp.createLoginRequest(idp, 'redirect'); + const originalURL = url.parse(context, true); + const SAMLRequest = originalURL.query.SAMLRequest; + const Signature = originalURL.query.Signature; + const SigAlg = originalURL.query.SigAlg; + delete originalURL.query.Signature; + const octetString = Object.keys(originalURL.query).map(q => q + '=' + encodeURIComponent(originalURL.query[q] as string)).join('&'); + const { extract } = await idp.parseLoginRequest(sp, 'redirect', { query: { SAMLRequest, Signature, SigAlg }, octetString}); + expect(extract.request.forceAuthn).toBe('false'); +}); + + test('signed in sp is not matched with the signed notation in idp with post request', () => { const _idp = identityProvider({ ...defaultIdpConfig, metadata: noSignedIdpMetadata }); try {