diff --git a/README.md b/README.md index aecb047..d0500bc 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ It provides support for seven passport based strategies. 10. [passport-cognito-oauth2](https://github.com/ebuychance/passport-cognito-oauth2) - Passport strategy for authenticating with Cognito using the Cognito OAuth 2.0 API. This module lets you authenticate using Cognito in your Node.js applications. 11. [passport-SAML](https://github.com/node-saml/passport-saml) - Passport strategy for authenticating with SAML using the SAML 2.0 API. This module lets you authenticate using SAML in your Node.js applications 12. custom-passport-otp - Created a Custom Passport strategy for 2-Factor-Authentication using OTP (One Time Password). +13. [passport-auth0](https://github.com/auth0/passport-auth0) - Passport strategy for authenticating with auth0. This module lets you authenticate using [Auth0](https://auth0.com/) in your Node.js applications. You can use one or more strategies of the above in your application. For each of the strategy (only which you use), you just need to provide your own verifier function, making it easily configurable. Rest of the strategy implementation intricacies is handled by extension. @@ -2977,6 +2978,346 @@ this.component(AuthenticationComponent); This binding needs to be done before adding the Authentication component to your application. Apart from this all other steps for authentication for all strategies remain the same. +### Passport Auth0 + +In order to use it, run `npm install passport-auth0` and `npm install @types/passport-auth0`. +First, create a AuthUser model implementing the IAuthUser interface. You can implement the interface in the user model itself. See sample below. + +```ts +@model({ + name: 'users', +}) +export class User extends Entity implements IAuthUser { + @property({ + type: 'number', + id: true, + }) + id?: number; + + @property({ + type: 'string', + required: true, + name: 'first_name', + }) + firstName: string; + + @property({ + type: 'string', + name: 'last_name', + }) + lastName: string; + + @property({ + type: 'string', + name: 'middle_name', + }) + middleName?: string; + + @property({ + type: 'string', + required: true, + }) + username: string; + + @property({ + type: 'string', + }) + email?: string; + + // Auth provider - 'auth0' + @property({ + type: 'string', + required: true, + name: 'auth_provider', + }) + authProvider: string; + + // Id from external provider + @property({ + type: 'string', + name: 'auth_id', + }) + authId?: string; + + @property({ + type: 'string', + name: 'auth_token', + }) + authToken?: string; + + @property({ + type: 'string', + }) + password?: string; + + constructor(data?: Partial) { + super(data); + } +} +``` + +Now bind this model to USER_MODEL key in application.ts + +```ts +this.bind(AuthenticationBindings.USER_MODEL).to(User); +``` + +Create CRUD repository for the above model. Use loopback CLI. + +```sh +lb4 repository +``` + +Add the verifier function for the strategy. You need to create a provider for the same. You can add your application specific business logic for client auth here. Here is a simple example. + +```ts +import {Provider, inject} from '@loopback/context'; +import {repository} from '@loopback/repository'; +import {HttpErrors} from '@loopback/rest'; +import { + AuthErrorKeys, + IAuthUser, + VerifyFunction, +} from 'loopback4-authentication'; +import * as Auth0Strategy from 'passport-auth0'; +import { + Auth0PostVerifyFn, + Auth0PreVerifyFn, + Auth0SignUpFn, +} from '../../../providers'; +import {SignUpBindings, VerifyBindings} from '../../../providers/keys'; +import {UserCredentialsRepository, UserRepository} from '../../../repositories'; +import {AuthUser} from '../models'; + +export class Auth0VerifyProvider implements Provider { + constructor( + @repository(UserRepository) + public userRepository: UserRepository, + @repository(UserCredentialsRepository) + public userCredsRepository: UserCredentialsRepository, + ) {} + + value(): VerifyFunction.Auth0Fn { + return async ( + accessToken: string, + refreshToken: string, + profile: Auth0Strategy.Profile, + ) => { + let user: IAuthUser | null = await this.userRepository.findOne({ + where: { + email: profile._json.email, + }, + }); + + if (!user) { + throw new HttpErrors.Unauthorized(AuthErrorKeys.InvalidCredentials); + } + } + const creds = await this.userCredsRepository.findOne({ + where: { + userId: user.id as string, + }, + }); + if ( + !user || + user.authProvider !== 'auth0' || + user.authId !== profile.id + ) { + throw new HttpErrors.Unauthorized(AuthErrorKeys.InvalidCredentials); + } + + const authUser: AuthUser = new AuthUser(user); + authUser.permissions = []; + authUser.externalAuthToken = accessToken; + authUser.externalRefreshToken = refreshToken; + return this.postVerifyProvider(profile, authUser); + }; + } +} +``` + +Please note the Verify function type _VerifyFunction.LocalPasswordFn_ + +Now bind this provider to the application in application.ts. + +```ts +import {AuthenticationComponent, Strategies} from 'loopback4-authentication'; +``` + +```ts +// Add authentication component +this.component(AuthenticationComponent); +// Customize authentication verify handlers +this.bind(Strategies.Passport.AUTH0_VERIFIER).toProvider(Auth0VerifyProvider); +``` + +Now, bind this provider to the application in application.ts. + +```ts +import {Auth0StrategyFactoryProvider} from 'loopback4-authentication/passport-auth0'; +``` + +```ts +this.bind(Strategies.Passport.AUTH0_STRATEGY_FACTORY).toProvider( + Auth0StrategyFactoryProvider, +); +``` + +Finally, add the authenticate function as a sequence action to sequence.ts. + +```ts +export class MySequence implements SequenceHandler { + constructor( + @inject(SequenceActions.FIND_ROUTE) protected findRoute: FindRoute, + @inject(SequenceActions.PARSE_PARAMS) protected parseParams: ParseParams, + @inject(SequenceActions.INVOKE_METHOD) protected invoke: InvokeMethod, + @inject(SequenceActions.SEND) public send: Send, + @inject(SequenceActions.REJECT) public reject: Reject, + @inject(AuthenticationBindings.USER_AUTH_ACTION) + protected authenticateRequest: AuthenticateFn, + ) {} + + async handle(context: RequestContext) { + try { + const {request, response} = context; + + const route = this.findRoute(request); + const args = await this.parseParams(request, route); + request.body = args[args.length - 1]; + const authUser: AuthUser = await this.authenticateRequest( + request, + response, + ); + const result = await this.invoke(route, args); + this.send(response, result); + } catch (err) { + this.reject(context, err); + } + } +} +``` + +After this, you can use decorator to apply auth to controller functions wherever needed. See below. + +```ts +@authenticateClient(STRATEGY.CLIENT_PASSWORD) + @authenticate( + STRATEGY.AUTH0, + { + domain: process.env.AUTH0_DOMAIN, + clientID: process.env.AUTH0_CLIENT_ID, + clientSecret: process.env.AUTH0_CLIENT_SECRET, + callbackURL: process.env.AUTH0_CALLBACK_URL, + state: false, + profileFields: ['email', 'name'], + scope: 'openid email profile', + }, + (req: Request) => { + return { + accessType: 'offline', + state: Object.keys(req.query) + .map(key => key + '=' + req.query[key]) + .join('&'), + }; + }, + ) + @authorize(['*']) + @get('/auth/auth0', { + responses: { + [STATUS_CODE.OK]: { + description: 'Token Response', + content: { + [CONTENT_TYPE.JSON]: { + schema: {'x-ts-type': TokenResponse}, + }, + }, + }, + }, + }) + async loginViaAuth0( + @param.query.string('client_id') + clientId?: string, + @param.query.string('client_secret') + clientSecret?: string, + ): Promise {} + + @authenticate( + STRATEGY.AUTH0, + { + domain: process.env.AUTH0_DOMAIN, + clientID: process.env.AUTH0_CLIENT_ID, + clientSecret: process.env.AUTH0_CLIENT_SECRET, + callbackURL: process.env.AUTH0_CALLBACK_URL, + state: false, + profileFields: ['email', 'name'], + scope: 'openid email profile', + } + (req: Request) => { + return { + accessType: 'offline', + state: Object.keys(req.query) + .map(key => `${key}=${req.query[key]}`) + .join('&'), + }; + }, + ) + @authorize(['*']) + @get('/auth/auth0-auth-redirect', { + responses: { + [STATUS_CODE.OK]: { + description: 'Token Response', + content: { + [CONTENT_TYPE.JSON]: { + schema: {'x-ts-type': TokenResponse}, + }, + }, + }, + }, + }) + async callback( + @param.query.string('code') code: string, + @param.query.string('state') state: string, + @inject(RestBindings.Http.RESPONSE) response: Response, + ): Promise { + const clientId = new URLSearchParams(state).get('client_id'); + if (!clientId || !this.user) { + throw new HttpErrors.Unauthorized(AuthErrorKeys.ClientInvalid); + } + const client = await this.authClientRepository.findOne({ + where: { + clientId: clientId, + }, + }); + if (!client || !client.redirectUrl) { + throw new HttpErrors.Unauthorized(AuthErrorKeys.ClientInvalid); + } + try { + const codePayload: ClientAuthCode = { + clientId, + user: this.user, + }; + const token = jwt.sign(codePayload, client.secret, { + expiresIn: client.authCodeExpiration, + audience: clientId, + subject: this.user.username, + issuer: process.env.JWT_ISSUER, + }); + response.redirect(`${client.redirectUrl}?code=${token}`); + } catch (error) { + throw new HttpErrors.InternalServerError(AuthErrorKeys.UnknownError); + } + } +``` + +Please note above that we are creating two new APIs for auth0 authentication. The first one is for UI clients to hit. We are authenticating client as well, then passing the details to the auth0. Then, the actual authentication is done by auth0 authorization url, which redirects to the second API we created after success. The first API method body is empty as we do not need to handle its response. The provider in this package will do the redirection for you automatically. + +For accessing the authenticated AuthUser model reference, you can inject the CURRENT_USER provider, provided by the extension, which is populated by the auth action sequence above. + +```ts + @inject.getter(AuthenticationBindings.CURRENT_USER) + private readonly getCurrentUser: Getter, +``` + ## Feedback If you've noticed a bug or have a question or have a feature request, [search the issue tracker](https://github.com/sourcefuse/loopback4-authentication/issues) to see if someone else in the community has already created a ticket. diff --git a/package-lock.json b/package-lock.json index 0f4cea9..3700b24 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "loopback4-authentication", - "version": "12.0.1", + "version": "12.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "loopback4-authentication", - "version": "12.0.1", + "version": "12.0.2", "license": "MIT", "dependencies": { "@loopback/context": "^7.0.2", @@ -40,6 +40,7 @@ "@types/node": "^18.11.9", "@types/passport": "^1.0.7", "@types/passport-apple": "^1.1.1", + "@types/passport-auth0": "^1.0.9", "@types/passport-azure-ad": "^4.3.1", "@types/passport-facebook": "^2.1.11", "@types/passport-google-oauth20": "^2.0.11", @@ -59,6 +60,7 @@ "lodash": "^4.17.21", "nyc": "^15.1.0", "passport-apple": "file:vendor/passport-apple", + "passport-auth0": "^1.4.4", "passport-azure-ad": "^4.3.4", "passport-cognito-oauth2": "^0.1.1", "passport-facebook": "^3.0.0", @@ -2533,6 +2535,16 @@ "@types/passport-oauth2": "*" } }, + "node_modules/@types/passport-auth0": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/passport-auth0/-/passport-auth0-1.0.9.tgz", + "integrity": "sha512-xHYzOkq0qy0U/4QyUnB5JzutGrLARd435Q/5rr0e2kkW3Q49UTJkrohgOibFyw66bfV7aupkDu/in5WfMqJZSg==", + "dev": true, + "dependencies": { + "@types/express": "*", + "@types/passport": "*" + } + }, "node_modules/@types/passport-azure-ad": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/@types/passport-azure-ad/-/passport-azure-ad-4.3.6.tgz", @@ -3373,6 +3385,17 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/axios": { + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.7.tgz", + "integrity": "sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==", + "dev": true, + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -6248,6 +6271,26 @@ "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", "dev": true }, + "node_modules/follow-redirects": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/for-each": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", @@ -13506,6 +13549,17 @@ "resolved": "vendor/passport-apple", "link": true }, + "node_modules/passport-auth0": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/passport-auth0/-/passport-auth0-1.4.4.tgz", + "integrity": "sha512-PFkjMfsfXSwgn94QCrZl2hObRHiqrAJffyeUvI8e8HqTG7MfOlyzWO3wSL5dlH+MUGR5+DQr+vtXFFu6Sx8cfg==", + "dev": true, + "dependencies": { + "axios": "^1.6.0", + "passport-oauth": "^1.0.0", + "passport-oauth2": "^1.6.0" + } + }, "node_modules/passport-azure-ad": { "version": "4.3.5", "resolved": "https://registry.npmjs.org/passport-azure-ad/-/passport-azure-ad-4.3.5.tgz", @@ -13617,6 +13671,37 @@ "node": ">= 0.4.0" } }, + "node_modules/passport-oauth": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-oauth/-/passport-oauth-1.0.0.tgz", + "integrity": "sha512-4IZNVsZbN1dkBzmEbBqUxDG8oFOIK81jqdksE3HEb/vI3ib3FMjbiZZ6MTtooyYZzmKu0BfovjvT1pdGgIq+4Q==", + "dev": true, + "dependencies": { + "passport-oauth1": "1.x.x", + "passport-oauth2": "1.x.x" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/passport-oauth1": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/passport-oauth1/-/passport-oauth1-1.3.0.tgz", + "integrity": "sha512-8T/nX4gwKTw0PjxP1xfD0QhrydQNakzeOpZ6M5Uqdgz9/a/Ag62RmJxnZQ4LkbdXGrRehQHIAHNAu11rCP46Sw==", + "dev": true, + "dependencies": { + "oauth": "0.9.x", + "passport-strategy": "1.x.x", + "utils-merge": "1.x.x" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, "node_modules/passport-oauth2": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.8.0.tgz", @@ -13993,6 +14078,12 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "dev": true + }, "node_modules/psl": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", diff --git a/package.json b/package.json index b6c1dda..1a29a67 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,10 @@ "type": "./dist/strategies/passport/passport-azure-ad/index.d.ts", "default": "./dist/strategies/passport/passport-azure-ad/index.js" }, + "./passport-auth0": { + "type": "./dist/strategies/passport/passport-auth0/index.d.ts", + "default": "./dist/strategies/passport/passport-auth0/index.js" + }, "./passport-cognito-oauth2": { "type": "./dist/strategies/passport/passport-cognito-oauth2/index.d.ts", "default": "./dist/strategies/passport/passport-cognito-oauth2/index.js" @@ -62,6 +66,9 @@ "passport-apple-oauth2": [ "./dist/strategies/passport/passport-apple-oauth2/index.d.ts" ], + "passport-auth0": [ + "./dist/strategies/passport/passport-auth0/index.d.ts" + ], "passport-azure-ad": [ "./dist/strategies/passport/passport-azure-ad/index.d.ts" ], @@ -150,11 +157,13 @@ "devDependencies": { "@commitlint/cli": "^17.7.1", "@commitlint/config-conventional": "^17.7.0", + "@exlinc/keycloak-passport": "^1.0.2", "@istanbuljs/nyc-config-typescript": "^1.0.2", "@loopback/boot": "^7.0.2", "@loopback/build": "^11.0.2", "@loopback/metadata": "^7.0.2", "@loopback/testlab": "^7.0.2", + "@node-saml/passport-saml": "^4.0.4", "@semantic-release/changelog": "^6.0.1", "@semantic-release/commit-analyzer": "^9.0.2", "@semantic-release/git": "^10.0.1", @@ -166,6 +175,7 @@ "@types/node": "^18.11.9", "@types/passport": "^1.0.7", "@types/passport-apple": "^1.1.1", + "@types/passport-auth0": "^1.0.9", "@types/passport-azure-ad": "^4.3.1", "@types/passport-facebook": "^2.1.11", "@types/passport-google-oauth20": "^2.0.11", @@ -184,14 +194,8 @@ "jsdom": "^21.0.0", "lodash": "^4.17.21", "nyc": "^15.1.0", - "semantic-release": "^19.0.3", - "simple-git": "^3.15.1", - "source-map-support": "^0.5.21", - "ts-node": "^10.7.0", - "tslint": "^6.1.3", - "typescript": "~5.2.2", - "@node-saml/passport-saml": "^4.0.4", "passport-apple": "file:vendor/passport-apple", + "passport-auth0": "^1.4.4", "passport-azure-ad": "^4.3.4", "passport-cognito-oauth2": "^0.1.1", "passport-facebook": "^3.0.0", @@ -199,7 +203,12 @@ "passport-instagram": "^1.0.0", "passport-local": "^1.0.0", "passport-oauth2": "^1.6.1", - "@exlinc/keycloak-passport": "^1.0.2" + "semantic-release": "^19.0.3", + "simple-git": "^3.15.1", + "source-map-support": "^0.5.21", + "ts-node": "^10.7.0", + "tslint": "^6.1.3", + "typescript": "~5.2.2" }, "publishConfig": { "registry": "https://registry.npmjs.org/" diff --git a/src/__tests__/integration/action-sequence/passport-auth0/passport-auth0.integration.ts b/src/__tests__/integration/action-sequence/passport-auth0/passport-auth0.integration.ts new file mode 100644 index 0000000..65a99bd --- /dev/null +++ b/src/__tests__/integration/action-sequence/passport-auth0/passport-auth0.integration.ts @@ -0,0 +1,88 @@ +import {Application, Provider} from '@loopback/core'; +import {get} from '@loopback/openapi-v3'; +import {Request, RestServer} from '@loopback/rest'; +import {Client, createClientForHandler} from '@loopback/testlab'; +import Auth0Strategy from 'passport-auth0'; +import {authenticate} from '../../../../decorators'; +import {VerifyFunction} from '../../../../strategies'; +import {Strategies} from '../../../../strategies/keys'; +import {Auth0StrategyFactoryProvider} from '../../../../strategies/passport/passport-auth0'; +import {Auth0} from '../../../../strategies/types/auth0.types'; +import {STRATEGY} from '../../../../strategy-name.enum'; +import {userWithoutReqObj} from '../../../fixtures/data/bearer-data'; +import {MyAuthenticationSequence} from '../../../fixtures/sequences/authentication.sequence'; +import {getApp} from '../helpers/helpers'; + +describe('getting auth0 strategy with options', () => { + let app: Application; + let server: RestServer; + beforeEach(givenAServer); + beforeEach(givenAuthenticatedSequence); + beforeEach(getAuthVerifier); + afterEach(closeServer); + + it('should return 302 when client id is passed and passReqToCallback is set true', async () => { + getAuthVerifier(); + class TestController { + @get('/test') + @authenticate(STRATEGY.AUTH0, { + clientID: 'string', + clientSecret: 'string', + callbackURL: 'string', + domain: 'string', + passReqToCallback: true, + state: false, + }) + 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.Passport.AUTH0_STRATEGY_FACTORY) + .toProvider(Auth0StrategyFactoryProvider); + app + .bind(Strategies.Passport.AUTH0_VERIFIER) + .toProvider(Auth0VerifyProvider); + } + + function closeServer() { + app.close(); + } + + function givenAuthenticatedSequence() { + // bind user defined sequence + server.sequence(MyAuthenticationSequence); + } +}); + +class Auth0VerifyProvider implements Provider { + constructor() {} + + value(): VerifyFunction.Auth0Fn { + return async ( + accessToken: string, + refreshToken: string, + profile: Auth0Strategy.Profile, + cd: Auth0.VerifyCallback, + req?: Request, + ) => { + return userWithoutReqObj; + }; + } +} diff --git a/src/__tests__/integration/middleware-sequence/passport-auth0/passport-auth0.integration.ts b/src/__tests__/integration/middleware-sequence/passport-auth0/passport-auth0.integration.ts new file mode 100644 index 0000000..a39b76f --- /dev/null +++ b/src/__tests__/integration/middleware-sequence/passport-auth0/passport-auth0.integration.ts @@ -0,0 +1,89 @@ +import {Application, Provider} from '@loopback/core'; +import {get} from '@loopback/openapi-v3'; +import {Request, RestServer} from '@loopback/rest'; +import {Client, createClientForHandler} from '@loopback/testlab'; +import Auth0Strategy from 'passport-auth0'; +import {authenticate} from '../../../../decorators'; +import {VerifyFunction} from '../../../../strategies'; +import {Strategies} from '../../../../strategies/keys'; +import {Auth0StrategyFactoryProvider} from '../../../../strategies/passport/passport-auth0'; +import {Auth0} from '../../../../strategies/types/auth0.types'; +import {STRATEGY} from '../../../../strategy-name.enum'; +import {userWithoutReqObj} from '../../../fixtures/data/bearer-data'; + +import {MyAuthenticationMiddlewareSequence} from '../../../fixtures/sequences/authentication-middleware.sequence'; +import {getApp} from '../helpers/helpers'; + +describe('getting auth0 strategy with options using Middleware Sequence', () => { + let app: Application; + let server: RestServer; + beforeEach(givenAServer); + beforeEach(givenAuthenticatedSequence); + beforeEach(getAuthVerifier); + afterEach(closeServer); + + it('should return 302 when client id is passed and passReqToCallback is set true', async () => { + getAuthVerifier(); + class TestController { + @get('/test') + @authenticate(STRATEGY.AUTH0, { + clientID: 'string', + clientSecret: 'string', + callbackURL: 'string', + domain: 'string', + passReqToCallback: true, + state: false, + }) + 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.Passport.AUTH0_STRATEGY_FACTORY) + .toProvider(Auth0StrategyFactoryProvider); + app + .bind(Strategies.Passport.AUTH0_VERIFIER) + .toProvider(Auth0VerifyProvider); + } + + function closeServer() { + app.close(); + } + + function givenAuthenticatedSequence() { + // bind user defined sequence + server.sequence(MyAuthenticationMiddlewareSequence); + } +}); + +class Auth0VerifyProvider implements Provider { + constructor() {} + + value(): VerifyFunction.Auth0Fn { + return async ( + accessToken: string, + refreshToken: string, + profile: Auth0Strategy.Profile, + cd: Auth0.VerifyCallback, + req?: Request, + ) => { + return userWithoutReqObj; + }; + } +} diff --git a/src/__tests__/unit/passport-auth0/auth0-strategy.unit.ts b/src/__tests__/unit/passport-auth0/auth0-strategy.unit.ts new file mode 100644 index 0000000..f2087f8 --- /dev/null +++ b/src/__tests__/unit/passport-auth0/auth0-strategy.unit.ts @@ -0,0 +1,79 @@ +import {IAuthUser} from '../../../types'; +import {expect} from '@loopback/testlab'; +import * as Auth0Strategy from 'passport-auth0'; + +import { + Auth0StrategyFactoryProvider, + Auth0StrategyFactory, +} from '../../../strategies/passport/passport-auth0'; +import {Auth0} from '../../../strategies/types/auth0.types'; + +describe('getting auth0 strategy with options', () => { + it('should return strategy by passing options and passReqToCallback as true', async () => { + const strategyVerifier: Auth0StrategyFactory = await getStrategy(); + + const options: + | Auth0.Auth0StrategyOptions + | Auth0Strategy.StrategyOptionWithRequest = { + clientID: 'string', + clientSecret: 'string', + callbackURL: 'string', + domain: 'string', + passReqToCallback: true, + }; + + const auth0StrategyVerifier = strategyVerifier(options); + + expect(auth0StrategyVerifier).to.have.property('name'); + expect(auth0StrategyVerifier) + .to.have.property('authenticate') + .which.is.a.Function(); + }); + + it('should return strategy by passing options and passReqToCallback as false', async () => { + const strategyVerifier: Auth0StrategyFactory = await getStrategy(); + + const options: + | Auth0.Auth0StrategyOptions + | Auth0Strategy.StrategyOptionWithRequest = { + clientID: 'string', + clientSecret: 'string', + callbackURL: 'string', + domain: 'string', + passReqToCallback: false, + }; + + const auth0StrategyVerifier = strategyVerifier(options); + + expect(auth0StrategyVerifier).to.have.property('name'); + expect(auth0StrategyVerifier) + .to.have.property('authenticate') + .which.is.a.Function(); + }); +}); + +async function getStrategy() { + const provider = new Auth0StrategyFactoryProvider(verifierBearer); + + //this fuction will return a function which will then accept options. + return provider.value(); +} + +//returning a user +function verifierBearer( + accessToken: string, + refreshToken: string, + profile: Auth0Strategy.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/strategies/keys.ts b/src/strategies/keys.ts index b6ef550..3738f45 100644 --- a/src/strategies/keys.ts +++ b/src/strategies/keys.ts @@ -13,6 +13,7 @@ import {AppleAuthStrategyFactory} from './passport/passport-apple-oauth2'; import {FacebookAuthStrategyFactory} from './passport/passport-facebook-oauth2'; import {CognitoAuthStrategyFactory} from './passport/passport-cognito-oauth2'; import {SamlStrategyFactory} from './SAML'; +import {Auth0StrategyFactory} from './passport/passport-auth0'; export namespace Strategies { export namespace Passport { @@ -140,5 +141,13 @@ export namespace Strategies { export const SAML_VERIFIER = BindingKey.create( 'sf.passport.verifier.saml', ); + + export const AUTH0_STRATEGY_FACTORY = + BindingKey.create( + 'sf.passport.strategyFactory.auth0', + ); + export const AUTH0_VERIFIER = BindingKey.create( + 'sf.passport.verifier.auth0', + ); } } diff --git a/src/strategies/passport/passport-auth0/auth0-strategy-factory-provider.ts b/src/strategies/passport/passport-auth0/auth0-strategy-factory-provider.ts new file mode 100644 index 0000000..b99ce0f --- /dev/null +++ b/src/strategies/passport/passport-auth0/auth0-strategy-factory-provider.ts @@ -0,0 +1,96 @@ +import {Provider, inject} from '@loopback/core'; +import {HttpErrors} from '@loopback/rest'; +import {Strategies} from '../../../keys'; +import {VerifyFunction} from '../../../types'; +import {AuthErrorKeys} from '../../../error-keys'; +import {Auth0} from '../../types'; +import { + ExtraVerificationParams, + Profile, + Strategy, + StrategyOptionWithRequest, +} from 'passport-auth0'; +import {Request} from 'express'; + +export type Auth0StrategyFactory = ( + options: Auth0.Auth0StrategyOptions | StrategyOptionWithRequest, + verifierPassed?: VerifyFunction.Auth0Fn, +) => Strategy; + +export class Auth0StrategyFactoryProvider + implements Provider +{ + constructor( + @inject(Strategies.Passport.AUTH0_VERIFIER) + private readonly auth0Verifier: VerifyFunction.Auth0Fn, + ) {} + + value(): Auth0StrategyFactory { + return (options, verifier) => this.getAuth0Strategy(options, verifier); + } + + getAuth0Strategy( + options: Auth0.Auth0StrategyOptions | StrategyOptionWithRequest, + verifier?: VerifyFunction.Auth0Fn, + ): Strategy { + const verifyFn = verifier ?? this.auth0Verifier; + let strategy; + if (options?.passReqToCallback === true) { + strategy = new Strategy( + options, + // eslint-disable-next-line @typescript-eslint/no-misused-promises + async ( + req: Request, + accessToken: string, + refreshToken: string, + extraParams: ExtraVerificationParams, + profile: Profile, + cb: Auth0.VerifyCallback, + ) => { + try { + const user = await verifyFn( + accessToken, + refreshToken, + profile, + cb, + req, + ); + if (!user) { + throw new HttpErrors.Unauthorized( + AuthErrorKeys.InvalidCredentials, + ); + } + cb(undefined, user); + } catch (err) { + cb(err); + } + }, + ); + } else { + strategy = new Strategy( + options, + // eslint-disable-next-line @typescript-eslint/no-misused-promises + async ( + accessToken: string, + refreshToken: string, + extraParams: ExtraVerificationParams, + profile: Profile, + cb: Auth0.VerifyCallback, + ) => { + try { + const user = await verifyFn(accessToken, refreshToken, profile, cb); + if (!user) { + throw new HttpErrors.Unauthorized( + AuthErrorKeys.InvalidCredentials, + ); + } + cb(undefined, user); + } catch (err) { + cb(err); + } + }, + ); + } + return strategy; + } +} diff --git a/src/strategies/passport/passport-auth0/auth0-verify.provider.ts b/src/strategies/passport/passport-auth0/auth0-verify.provider.ts new file mode 100644 index 0000000..fbcd691 --- /dev/null +++ b/src/strategies/passport/passport-auth0/auth0-verify.provider.ts @@ -0,0 +1,22 @@ +import {Provider} from '@loopback/context'; +import {HttpErrors} from '@loopback/rest'; +import {VerifyFunction} from '../../../types'; +import {Request} from '@loopback/express'; +import * as Auth0Strategy from 'passport-auth0'; +import {Auth0} from '../../types/auth0.types'; + +export class Auth0VerifyProvider implements Provider { + value(): VerifyFunction.Auth0Fn { + return async ( + accessToken: string, + refreshToken: string, + profile: Auth0Strategy.Profile, + cb: Auth0.VerifyCallback, + req?: Request, + ) => { + throw new HttpErrors.NotImplemented( + `VerifyFunction.Auth0Fn is not implemented`, + ); + }; + } +} diff --git a/src/strategies/passport/passport-auth0/index.ts b/src/strategies/passport/passport-auth0/index.ts new file mode 100644 index 0000000..7e77080 --- /dev/null +++ b/src/strategies/passport/passport-auth0/index.ts @@ -0,0 +1,2 @@ +export * from './auth0-verify.provider'; +export * from './auth0-strategy-factory-provider'; diff --git a/src/strategies/types/auth0.types.ts b/src/strategies/types/auth0.types.ts new file mode 100644 index 0000000..f123312 --- /dev/null +++ b/src/strategies/types/auth0.types.ts @@ -0,0 +1,14 @@ +import {AnyObject} from '@loopback/repository'; +import {StrategyOption} from 'passport-auth0'; + +export namespace Auth0 { + export type VerifyCallback = ( + err?: AnyObject, + user?: AnyObject, + info?: AnyObject, + ) => void; + + export interface Auth0StrategyOptions extends StrategyOption { + passReqToCallback?: false; + } +} diff --git a/src/strategies/types/index.ts b/src/strategies/types/index.ts index 53695e9..5db0f42 100644 --- a/src/strategies/types/index.ts +++ b/src/strategies/types/index.ts @@ -1,3 +1,4 @@ export * from './types'; export * from './keycloak.types'; export * from './cognito.types'; +export * from './auth0.types'; diff --git a/src/strategies/types/types.ts b/src/strategies/types/types.ts index ad0cdec..9fc2e09 100644 --- a/src/strategies/types/types.ts +++ b/src/strategies/types/types.ts @@ -1,5 +1,5 @@ import {AnyObject} from '@loopback/repository'; -import {Request} from '@loopback/rest'; +import {Request} from '@loopback/express'; import * as SamlStrategy from '@node-saml/passport-saml'; import * as AppleStrategy from 'passport-apple'; import {DecodedIdToken} from 'passport-apple'; @@ -7,7 +7,14 @@ import * as AzureADStrategy from 'passport-azure-ad'; import * as FacebookStrategy from 'passport-facebook'; import * as GoogleStrategy from 'passport-google-oauth20'; import * as InstagramStrategy from 'passport-instagram'; -import {Cognito, IAuthClient, IAuthSecureClient, IAuthUser} from '../../types'; +import * as Auth0Strategy from 'passport-auth0'; +import { + Auth0, + Cognito, + IAuthClient, + IAuthSecureClient, + IAuthUser, +} from '../../types'; import {Keycloak} from './keycloak.types'; import {Otp} from '../passport/passport-otp'; @@ -120,6 +127,15 @@ export namespace VerifyFunction { req?: Request, ): Promise; } + export interface Auth0Fn extends GenericAuthFn { + ( + accessToken: string, + refreshToken: string, + profile: Auth0Strategy.Profile, + cb: Auth0.VerifyCallback, + req?: Request, + ): Promise; + } export interface AppleAuthFn extends GenericAuthFn { ( diff --git a/src/strategies/user-auth-strategy.provider.ts b/src/strategies/user-auth-strategy.provider.ts index b9e5e2a..ed91ca3 100644 --- a/src/strategies/user-auth-strategy.provider.ts +++ b/src/strategies/user-auth-strategy.provider.ts @@ -8,6 +8,7 @@ import * as GoogleStrategy from 'passport-google-oauth20'; import * as PassportBearer from 'passport-http-bearer'; import * as InstagramStrategy from 'passport-instagram'; import * as PassportLocal from 'passport-local'; +import * as Auth0Strategy from 'passport-auth0'; import {AuthenticationBindings} from '../keys'; import {STRATEGY} from '../strategy-name.enum'; import {AuthenticationMetadata, IAuthUser} from '../types'; @@ -15,7 +16,7 @@ import {Strategies} from './keys'; import {LocalPasswordStrategyFactory} from './passport/passport-local'; import {Otp} from './passport/passport-otp'; import {Oauth2ResourceOwnerPassword} from './passport/passport-resource-owner-password'; -import {Cognito, Keycloak, VerifyFunction} from './types'; +import {Auth0, Cognito, Keycloak, VerifyFunction} from './types'; interface ExtendedStrategyOption extends FacebookStrategy.StrategyOption { passReqToCallback?: false; @@ -241,6 +242,26 @@ export class AuthStrategyProvider implements Provider { verifier as VerifyFunction.OtpAuthFn, ); } + async processAuth0Factory(verifier: VerifierType) { + const auth0Factory = this.ctx.getSync( + Strategies.Passport.AUTH0_STRATEGY_FACTORY, + {optional: true}, + ); + + if (!auth0Factory) { + throw new Error( + `No factory found for ${Strategies.Passport.AUTH0_STRATEGY_FACTORY}`, + ); + } + + // Cast the factory output to `Strategy` type + return auth0Factory( + this.metadata.options as + | Auth0.Auth0StrategyOptions + | Auth0Strategy.StrategyOptionWithRequest, + verifier as VerifyFunction.Auth0Fn, + ); + } async processSamlFactory(verifier: VerifierType) { const samlFactory = this.ctx.getSync( @@ -311,6 +332,9 @@ export class AuthStrategyProvider implements Provider { case STRATEGY.SAML: { return this.processSamlFactory(verifier); } + case STRATEGY.AUTH0: { + return this.processAuth0Factory(verifier); + } default: return Promise.reject( new Error(`The strategy ${name} is not available.`), diff --git a/src/strategy-name.enum.ts b/src/strategy-name.enum.ts index 3fe114d..12e93ca 100644 --- a/src/strategy-name.enum.ts +++ b/src/strategy-name.enum.ts @@ -12,4 +12,5 @@ export const enum STRATEGY { KEYCLOAK = 'keycloak', OTP = 'otp', SAML = 'saml', + AUTH0 = 'auth0', }