diff --git a/src/__tests__/fixtures/providers/saml.provider.ts b/src/__tests__/fixtures/providers/saml.provider.ts new file mode 100644 index 0000000..fb27785 --- /dev/null +++ b/src/__tests__/fixtures/providers/saml.provider.ts @@ -0,0 +1,29 @@ +import {Provider} from '@loopback/core'; +import {VerifyFunction} from '../../../strategies'; +import * as SamlStrategy from 'passport-saml'; +import {IAuthUser} from '../../../types'; +import {Request} from '@loopback/rest'; + +export class BearerTokenVerifyProvider + implements Provider +{ + constructor() { + //this is intentional + } + + value(): VerifyFunction.SamlFn { + return async ( + profile: SamlStrategy.Profile, + cb: SamlStrategy.VerifiedCallback, + req?: Request, + ) => { + const userToPass: IAuthUser = { + id: 1, + username: 'xyz', + password: 'pass', + }; + + return userToPass; + }; + } +} diff --git a/src/__tests__/integration/action-sequence/saml/saml.integration.ts b/src/__tests__/integration/action-sequence/saml/saml.integration.ts new file mode 100644 index 0000000..a41fd8d --- /dev/null +++ b/src/__tests__/integration/action-sequence/saml/saml.integration.ts @@ -0,0 +1,71 @@ +import {Client, createClientForHandler} from '@loopback/testlab'; +import {RestServer, Request} from '@loopback/rest'; +import {Application, Provider} from '@loopback/core'; +import {get} from '@loopback/openapi-v3'; +import {authenticate} from '../../../../decorators'; +import {STRATEGY} from '../../../../strategy-name.enum'; +import {getApp} from '../helpers/helpers'; +import {MyAuthenticationSequence} from '../../../fixtures/sequences/authentication.sequence'; +import {Strategies} from '../../../../strategies/keys'; +import {VerifyFunction} from '../../../../strategies'; +import {userWithoutReqObj} from '../../../fixtures/data/bearer-data'; +import * as SamlStrategy from 'passport-saml'; + +describe('getting saml strategy with options', () => { + let app: Application; + let server: RestServer; + beforeEach(givenAServer); + beforeEach(givenAuthenticatedSequence); + beforeEach(getAuthVerifier); + + it('should return 302 when name is passed and passReqToCallback is set true', async () => { + class TestController { + @get('/test') + @authenticate(STRATEGY.SAML, { + name: 'string', + passReqToCallback: true, + }) + test() { + return 'test successful'; + } + } + + app.controller(TestController); + + await whenIMakeRequestTo(server).get('/test').expect(302); + }); + + function whenIMakeRequestTo(restServer: RestServer): Client { + return createClientForHandler(restServer.requestHandler); + } + + async function givenAServer() { + app = getApp(); + server = await app.getServer(RestServer); + } + + function getAuthVerifier() { + app.bind(Strategies.SAML_VERIFIER).toProvider(SamlVerifyProvider); + } + + function givenAuthenticatedSequence() { + // bind user defined sequence + server.sequence(MyAuthenticationSequence); + } +}); + +class SamlVerifyProvider implements Provider { + constructor() { + //this is intentional + } + + value(): VerifyFunction.SamlFn { + return async ( + profile: SamlStrategy.Profile, + cd: SamlStrategy.VerifiedCallback, + req?: Request, + ) => { + return userWithoutReqObj; + }; + } +} diff --git a/src/__tests__/integration/middleware-sequence/saml/saml.integration.ts b/src/__tests__/integration/middleware-sequence/saml/saml.integration.ts new file mode 100644 index 0000000..b348184 --- /dev/null +++ b/src/__tests__/integration/middleware-sequence/saml/saml.integration.ts @@ -0,0 +1,71 @@ +import {Client, createClientForHandler} from '@loopback/testlab'; +import {RestServer, Request} from '@loopback/rest'; +import {Application, Provider} from '@loopback/core'; +import {get} from '@loopback/openapi-v3'; +import {authenticate} from '../../../../decorators'; +import {STRATEGY} from '../../../../strategy-name.enum'; +import {getApp} from '../helpers/helpers'; +import {MyAuthenticationMiddlewareSequence} from '../../../fixtures/sequences/authentication-middleware.sequence'; +import {Strategies} from '../../../../strategies/keys'; +import {VerifyFunction} from '../../../../strategies'; +import {userWithoutReqObj} from '../../../fixtures/data/bearer-data'; +import * as SamlStrategy from 'passport-saml'; + +describe('getting saml strategy with options using Middleware Sequence', () => { + let app: Application; + let server: RestServer; + beforeEach(givenAServer); + beforeEach(givenAuthenticatedSequence); + beforeEach(getAuthVerifier); + + it('should return 302 when name is passed and passReqToCallback is set true', async () => { + class TestController { + @get('/test') + @authenticate(STRATEGY.SAML, { + name: 'string', + passReqToCallback: true, + }) + test() { + return 'test successful'; + } + } + + app.controller(TestController); + + await whenIMakeRequestTo(server).get('/test').expect(302); + }); + + function whenIMakeRequestTo(restServer: RestServer): Client { + return createClientForHandler(restServer.requestHandler); + } + + async function givenAServer() { + app = getApp(); + server = await app.getServer(RestServer); + } + + function getAuthVerifier() { + app.bind(Strategies.SAML_VERIFIER).toProvider(SamlVerifyProvider); + } + + function givenAuthenticatedSequence() { + // bind user defined sequence + server.sequence(MyAuthenticationMiddlewareSequence); + } +}); + +class SamlVerifyProvider implements Provider { + constructor() { + //this is intentional + } + + value(): VerifyFunction.SamlFn { + return async ( + profile: SamlStrategy.Profile, + cd: SamlStrategy.VerifiedCallback, + req?: Request, + ) => { + return userWithoutReqObj; + }; + } +} diff --git a/src/__tests__/unit/saml-strategy.unit.ts/saml-strategy.unit.ts b/src/__tests__/unit/saml-strategy.unit.ts/saml-strategy.unit.ts new file mode 100644 index 0000000..b91e75d --- /dev/null +++ b/src/__tests__/unit/saml-strategy.unit.ts/saml-strategy.unit.ts @@ -0,0 +1,66 @@ +import {IAuthUser} from '../../../types'; +import {expect} from '@loopback/testlab'; +import * as SamlStrategy from 'passport-saml'; +import { + SamlStrategyFactoryProvider, + SamlStrategyFactory, +} from '../../../strategies/SAML'; +import {StrategyOptions} from 'passport-saml/lib/passport-saml/types'; + +describe('getting saml strategy with options', () => { + it('should return strategy by passing options and passReqToCallback as true', async () => { + const strategyVerifier: SamlStrategyFactory = await getStrategy(); + + const options: StrategyOptions = { + name: 'string', + passReqToCallback: true, + }; + + const SamlStrategyVerifier = strategyVerifier(options); + + expect(SamlStrategyVerifier).to.have.property('name'); + expect(SamlStrategyVerifier) + .to.have.property('authenticate') + .which.is.a.Function(); + }); + + it('should return strategy by passing options and passReqToCallback as false', async () => { + const strategyVerifier: SamlStrategyFactory = await getStrategy(); + + const options: StrategyOptions = { + name: 'string', + passReqToCallback: false, + }; + + const SamlStrategyVerifier = strategyVerifier(options); + + expect(SamlStrategyVerifier).to.have.property('name'); + expect(SamlStrategyVerifier) + .to.have.property('authenticate') + .which.is.a.Function(); + }); +}); + +async function getStrategy() { + const provider = new SamlStrategyFactoryProvider(verifierBearer); + + //this fuction will return a function which will then accept options. + return provider.value(); +} + +//returning a user +function verifierBearer( + profile: SamlStrategy.Profile, +): Promise { + const userToPass: IAuthUser = { + id: 1, + username: 'xyz', + password: 'pass', + }; + + return new Promise(function (resolve, reject) { + if (userToPass) { + resolve(userToPass); + } + }); +} diff --git a/src/component.ts b/src/component.ts index dffc27e..c2e0fd9 100644 --- a/src/component.ts +++ b/src/component.ts @@ -37,6 +37,8 @@ import { ResourceOwnerVerifyProvider, PassportOtpStrategyFactoryProvider, OtpVerifyProvider, + SamlStrategyFactoryProvider, + SamlVerifyProvider, } from './strategies'; import {Strategies} from './strategies/keys'; import { @@ -84,6 +86,7 @@ export class AuthenticationComponent implements Component { KeycloakStrategyFactoryProvider, [Strategies.Passport.COGNITO_OAUTH2_STRATEGY_FACTORY.key]: CognitoStrategyFactoryProvider, + [Strategies.SAML_STRATEGY_FACTORY.key]: SamlStrategyFactoryProvider, // Verifier functions [Strategies.Passport.OAUTH2_CLIENT_PASSWORD_VERIFIER.key]: @@ -106,6 +109,7 @@ export class AuthenticationComponent implements Component { [Strategies.Passport.APPLE_OAUTH2_VERIFIER.key]: AppleAuthVerifyProvider, [Strategies.Passport.AZURE_AD_VERIFIER.key]: AzureADAuthVerifyProvider, [Strategies.Passport.KEYCLOAK_VERIFIER.key]: KeycloakVerifyProvider, + [Strategies.SAML_VERIFIER.key]: SamlVerifyProvider, }; this.bindings = []; if (this.config?.useClientAuthenticationMiddleware) { diff --git a/src/strategies/SAML/index.ts b/src/strategies/SAML/index.ts new file mode 100644 index 0000000..8792d4e --- /dev/null +++ b/src/strategies/SAML/index.ts @@ -0,0 +1,2 @@ +export * from './saml-strategy-factory-provider'; +export * from './saml-verify.provider'; diff --git a/src/strategies/SAML/saml-strategy-factory-provider.ts b/src/strategies/SAML/saml-strategy-factory-provider.ts new file mode 100644 index 0000000..650e816 --- /dev/null +++ b/src/strategies/SAML/saml-strategy-factory-provider.ts @@ -0,0 +1,96 @@ +// SONAR-IGNORE-ALL +import {inject, Provider} from '@loopback/core'; +import {HttpErrors, Request} from '@loopback/rest'; +import {AnyObject} from '@loopback/repository'; +import {HttpsProxyAgent} from 'https-proxy-agent'; +import {Profile, Strategy, VerifiedCallback} from 'passport-saml'; +import { + SamlConfig, + StrategyOptions, +} from 'passport-saml/lib/passport-saml/types'; +import {AuthErrorKeys} from '../../error-keys'; +import {Strategies} from '../../keys'; +import {VerifyFunction} from '../../types'; + +export interface SamlStrategyFactory { + (options: StrategyOptions, verifierPassed?: VerifyFunction.SamlFn): Strategy; +} + +export class SamlStrategyFactoryProvider + implements Provider +{ + constructor( + @inject(Strategies.SAML_VERIFIER) + private readonly verifierSaml: VerifyFunction.SamlFn, + ) {} + + value(): SamlStrategyFactory { + return (options, verifier) => + this.getSamlStrategyVerifier(options, verifier); + } + + getSamlStrategyVerifier( + options: StrategyOptions, + verifierPassed?: VerifyFunction.SamlFn, + ): Strategy { + const verifyFn = verifierPassed ?? this.verifierSaml; + let strategy; + const func = async ( + req: Request, + profile: Profile | null | undefined, + cb: VerifiedCallback, + ) => { + try { + const user = await verifyFn(profile, cb, req); + if (!user) { + throw new HttpErrors.Unauthorized(AuthErrorKeys.InvalidCredentials); + } + cb(null, user as unknown as Record); + } catch (err) { + cb(err); + } + }; + if (options && options.passReqToCallback === true) { + strategy = new Strategy( + options as SamlConfig, + // eslint-disable-next-line @typescript-eslint/no-misused-promises + func, + ); + } else { + strategy = new Strategy( + options as SamlConfig, + // eslint-disable-next-line @typescript-eslint/no-misused-promises + async (profile: Profile | null | undefined, cb: VerifiedCallback) => { + try { + const user = await verifyFn(profile, cb); + if (!user) { + throw new HttpErrors.Unauthorized( + AuthErrorKeys.InvalidCredentials, + ); + } + cb(null, user as unknown as Record); + } catch (err) { + cb(err); + } + }, + ); + } + this._setupProxy(strategy); + return strategy; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private _setupProxy(strategy: AnyObject) { + // Setup proxy if any + let httpsProxyAgent; + if (process.env['https_proxy']) { + httpsProxyAgent = new HttpsProxyAgent(process.env['https_proxy']); + strategy._oauth2.setAgent(httpsProxyAgent); + } else if (process.env['HTTPS_PROXY']) { + httpsProxyAgent = new HttpsProxyAgent(process.env['HTTPS_PROXY']); + strategy._oauth2.setAgent(httpsProxyAgent); + } else { + //this is intentional + } + } +} diff --git a/src/strategies/SAML/saml-verify.provider.ts b/src/strategies/SAML/saml-verify.provider.ts new file mode 100644 index 0000000..caec92a --- /dev/null +++ b/src/strategies/SAML/saml-verify.provider.ts @@ -0,0 +1,29 @@ +import {Provider} from '@loopback/context'; +import {HttpErrors, Request} from '@loopback/rest'; + +import * as SamlStrategy from 'passport-saml'; + +import {VerifyFunction} from '../../types'; + +/** + * A provider for default implementation of VerifyFunction.LocalPasswordFn + * + * It will just throw an error saying Not Implemented + */ +export class SamlVerifyProvider implements Provider { + constructor() { + //This is intentional + } + + value(): VerifyFunction.SamlFn { + return async ( + profile: SamlStrategy.Profile, + cb: SamlStrategy.VerifiedCallback, + req?: Request, + ) => { + throw new HttpErrors.NotImplemented( + `VerifyFunction.SamlFn is not implemented`, + ); + }; + } +} diff --git a/src/strategies/index.ts b/src/strategies/index.ts index 6e98671..758aab5 100644 --- a/src/strategies/index.ts +++ b/src/strategies/index.ts @@ -2,3 +2,4 @@ export * from './client-auth-strategy.provider'; export * from './user-auth-strategy.provider'; export * from './passport'; export * from './types'; +export * from './SAML'; diff --git a/src/strategies/keys.ts b/src/strategies/keys.ts index 065c6ed..0485f47 100644 --- a/src/strategies/keys.ts +++ b/src/strategies/keys.ts @@ -11,6 +11,7 @@ import {InstagramAuthStrategyFactoryProvider} from './passport'; import {AppleAuthStrategyFactoryProvider} from './passport/passport-apple-oauth2'; import {FacebookAuthStrategyFactoryProvider} from './passport/passport-facebook-oauth2'; import {CognitoStrategyFactoryProvider} from './passport/passport-cognito-oauth2'; +import {SamlStrategyFactoryProvider} from './SAML/saml-strategy-factory-provider'; export namespace Strategies { export namespace Passport { @@ -133,4 +134,12 @@ export namespace Strategies { 'sf.passport.verifier.cognitoOauth2', ); } + // SAML strategy + export const SAML_STRATEGY_FACTORY = + BindingKey.create( + 'sf.passport.strategyFactory.saml', + ); + export const SAML_VERIFIER = BindingKey.create( + 'sf.passport.verifier.saml', + ); } diff --git a/src/strategies/types/types.ts b/src/strategies/types/types.ts index 8ee3512..b17f21f 100644 --- a/src/strategies/types/types.ts +++ b/src/strategies/types/types.ts @@ -4,6 +4,7 @@ import * as AzureADStrategy from 'passport-azure-ad'; import * as InstagramStrategy from 'passport-instagram'; import * as FacebookStrategy from 'passport-facebook'; import * as AppleStrategy from 'passport-apple'; +import * as SamlStrategy from 'passport-saml'; import {DecodedIdToken} from 'passport-apple'; import {Cognito, IAuthClient, IAuthUser} from '../../types'; import {Keycloak} from './keycloak.types'; @@ -115,6 +116,13 @@ export namespace VerifyFunction { req?: Request, ): Promise; } + export interface SamlFn extends GenericAuthFn { + ( + profile: SamlStrategy.Profile, + cb: SamlStrategy.VerifiedCallback, + req?: Request, + ): Promise; + } // eslint-disable-next-line @typescript-eslint/no-explicit-any export interface GenericAuthFn { diff --git a/src/strategies/user-auth-strategy.provider.ts b/src/strategies/user-auth-strategy.provider.ts index e380165..28442fc 100644 --- a/src/strategies/user-auth-strategy.provider.ts +++ b/src/strategies/user-auth-strategy.provider.ts @@ -7,6 +7,7 @@ import * as PassportLocal from 'passport-local'; import * as InstagramStrategy from 'passport-instagram'; import * as FacebookStrategy from 'passport-facebook'; import * as AppleStrategy from 'passport-apple'; +import * as SamlStrategy from 'passport-saml'; import {AuthenticationBindings} from '../keys'; import {STRATEGY} from '../strategy-name.enum'; @@ -29,6 +30,7 @@ import { Otp, CognitoAuthStrategyFactory, } from './passport'; +import {SamlStrategyFactory} from './SAML'; import {Cognito, Keycloak, VerifyFunction} from './types'; interface ExtendedStrategyOption extends FacebookStrategy.StrategyOption { @@ -62,6 +64,8 @@ export class AuthStrategyProvider implements Provider { private readonly getAppleAuthVerifier: AppleAuthStrategyFactory, @inject(Strategies.Passport.COGNITO_OAUTH2_STRATEGY_FACTORY) private readonly getCognitoAuthVerifier: CognitoAuthStrategyFactory, + @inject(Strategies.SAML_STRATEGY_FACTORY) + private readonly getSamlVerifier: SamlStrategyFactory, ) {} async value(): Promise { @@ -146,6 +150,11 @@ export class AuthStrategyProvider implements Provider { this.metadata.options as Otp.StrategyOptions, verifier as VerifyFunction.OtpAuthFn, ); + } else if (name === STRATEGY.SAML) { + return this.getSamlVerifier( + this.metadata.options as SamlStrategy.Strategy, + verifier as VerifyFunction.SamlFn, + ); } else { return Promise.reject(`The strategy ${name} is not available.`); } diff --git a/src/strategy-adapter.ts b/src/strategy-adapter.ts index 40f8d9b..4f0201f 100644 --- a/src/strategy-adapter.ts +++ b/src/strategy-adapter.ts @@ -61,7 +61,7 @@ export class StrategyAdapter { strategy.redirect = (url: string) => { if (response) { - response.redirect(url, 302); + response.redirect(302, url); } resolve(); }; diff --git a/src/strategy-name.enum.ts b/src/strategy-name.enum.ts index cad2ebb..3fe114d 100644 --- a/src/strategy-name.enum.ts +++ b/src/strategy-name.enum.ts @@ -11,4 +11,5 @@ export const enum STRATEGY { AZURE_AD = 'Azure AD', KEYCLOAK = 'keycloak', OTP = 'otp', + SAML = 'saml', }