diff --git a/examples/passport-login/data/db.json b/examples/passport-login/data/db.json index 6d3d401ee092..59fcd53b4b78 100644 --- a/examples/passport-login/data/db.json +++ b/examples/passport-login/data/db.json @@ -1,15 +1,15 @@ { "ids": { - "User": 2, - "UserIdentity": 100487451443373280000 + "User": 6, + "UserIdentity": 104566694532673, + "UserCredentials": 3 }, "models": { "User": { - "1": "{\"name\":\"Open Graph Test User\",\"username\":\"dplojbdiuc_1585460374@tfbnw.net\",\"email\":\"dplojbdiuc_1585460374@tfbnw.net\",\"id\":1}" }, "UserIdentity": { - "104566694532668": "{\"id\":\"104566694532668\",\"provider\":\"facebook\",\"profile\":{\"emails\":[{\"value\":\"dplojbdiuc_1585460374@tfbnw.net\"}]},\"authScheme\":\"facebook\",\"created\":\"2020-04-07T18:40:25.360Z\",\"userId\":1}", - "100487451443373281118": "{\"id\":\"100487451443373281118\",\"provider\":\"google\",\"profile\":{\"emails\":[{\"value\":\"deepak.r.kris@gmail.com\",\"type\":\"account\"}]},\"authScheme\":\"google\",\"created\":\"2020-04-07T18:40:52.941Z\",\"userId\":1}" + }, + "UserCredentials": { } } } \ No newline at end of file diff --git a/examples/passport-login/oauth2-providers.json b/examples/passport-login/data/oauth2-test-provider.json similarity index 72% rename from examples/passport-login/oauth2-providers.json rename to examples/passport-login/data/oauth2-test-provider.json index faeed56cf251..8c81d4dab41f 100644 --- a/examples/passport-login/oauth2-providers.json +++ b/examples/passport-login/data/oauth2-test-provider.json @@ -5,7 +5,7 @@ "profileFields": ["gender", "link", "locale", "name", "timezone", "verified", "email", "updated_time", "displayName", "id"], "clientID": "{facebook-client-id}", - "clientSecret": "ad4315e3453b012f6e4b8dd4bc1c0ae6", + "clientSecret": "{facebook-client-secret}", "callbackURL": "/api/auth/thirdparty/facebook/callback", "authPath": "/api/auth/thirdparty/facebook", "callbackPath": "/api/auth/thirdparty/facebook/callback", @@ -19,7 +19,7 @@ "module": "passport-google-oauth2", "strategy": "OAuth2Strategy", "clientID": "{google-client-id}", - "clientSecret": "FcldSqY5-9usqlRmQhZawN__", + "clientSecret": "{google-client-secret}", "callbackURL": "/api/auth/thirdparty/google/callback", "authPath": "/api/auth/thirdparty/google", "callbackPath": "/api/auth/thirdparty/google/callback", @@ -30,18 +30,18 @@ }, "oauth2": { "provider": "oauth2", - "module": "passport-google-oauth2", + "module": "passport-oauth2", "strategy": "OAuth2Strategy", - "clientID": "{google-client-id}", - "clientSecret": "FcldSqY5-9usqlRmQhZawN__", - "callbackURL": "/api/auth/thirdparty/google/callback", - "authPath": "/api/auth/thirdparty/google", - "callbackPath": "/api/auth/thirdparty/google/callback", + "authPath": "/api/auth/thirdparty/oauth2", + "callbackPath": "/api/auth/thirdparty/oauth2/callback", "successRedirect": "/auth/account", "failureRedirect": "/login", "scope": ["email", "profile"], "failureFlash": true, - "authorizationURL": "https://localhost:8080", - "tokenURL": "https://localhost:8080" + "clientID": "1111", + "clientSecret": "app1_secret", + "callbackURL": "http://localhost:3000/api/auth/thirdparty/oauth2/callback", + "authorizationURL": "http://localhost:9000/oauth/dialog", + "tokenURL": "http://localhost:9000/oauth/token" } } diff --git a/examples/passport-login/index.js b/examples/passport-login/index.js index 23fa79756090..2a5e6eb9b3ee 100644 --- a/examples/passport-login/index.js +++ b/examples/passport-login/index.js @@ -8,22 +8,7 @@ const application = require('./dist'); module.exports = application; if (require.main === module) { - // Run the application - const config = { - rest: { - port: +(process.env.PORT || 3000), - host: process.env.HOST, - protocol: 'http', - gracePeriodForClose: 5000, // 5 seconds - openApiSpec: { - // useful when used with OpenAPI-to-GraphQL to locate your application - setServersFromRequest: true, - }, - // Use the LB4 application as a route. It should not be listening. - listenOnStart: false, - }, - }; - application.main(config).catch(err => { + application.main().catch(err => { console.error('Cannot start the application.', err); process.exit(1); }); diff --git a/examples/passport-login/index.ts b/examples/passport-login/index.ts index 8420b1093fdb..98dc8d71ea92 100644 --- a/examples/passport-login/index.ts +++ b/examples/passport-login/index.ts @@ -1 +1,6 @@ +// Copyright IBM Corp. 2020. All Rights Reserved. +// Node module: @loopback/example-passport-login +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + export * from './src'; diff --git a/examples/passport-login/oauth2-providers.template.json b/examples/passport-login/oauth2-providers.template.json new file mode 100644 index 000000000000..8c81d4dab41f --- /dev/null +++ b/examples/passport-login/oauth2-providers.template.json @@ -0,0 +1,47 @@ +{ + "facebook-login": { + "provider": "facebook", + "module": "passport-facebook", + "profileFields": ["gender", "link", "locale", "name", "timezone", + "verified", "email", "updated_time", "displayName", "id"], + "clientID": "{facebook-client-id}", + "clientSecret": "{facebook-client-secret}", + "callbackURL": "/api/auth/thirdparty/facebook/callback", + "authPath": "/api/auth/thirdparty/facebook", + "callbackPath": "/api/auth/thirdparty/facebook/callback", + "successRedirect": "/auth/account", + "failureRedirect": "/login", + "scope": ["email"], + "failureFlash": true + }, + "google-login": { + "provider": "google", + "module": "passport-google-oauth2", + "strategy": "OAuth2Strategy", + "clientID": "{google-client-id}", + "clientSecret": "{google-client-secret}", + "callbackURL": "/api/auth/thirdparty/google/callback", + "authPath": "/api/auth/thirdparty/google", + "callbackPath": "/api/auth/thirdparty/google/callback", + "successRedirect": "/auth/account", + "failureRedirect": "/login", + "scope": ["email", "profile"], + "failureFlash": true + }, + "oauth2": { + "provider": "oauth2", + "module": "passport-oauth2", + "strategy": "OAuth2Strategy", + "authPath": "/api/auth/thirdparty/oauth2", + "callbackPath": "/api/auth/thirdparty/oauth2/callback", + "successRedirect": "/auth/account", + "failureRedirect": "/login", + "scope": ["email", "profile"], + "failureFlash": true, + "clientID": "1111", + "clientSecret": "app1_secret", + "callbackURL": "http://localhost:3000/api/auth/thirdparty/oauth2/callback", + "authorizationURL": "http://localhost:9000/oauth/dialog", + "tokenURL": "http://localhost:9000/oauth/token" + } +} diff --git a/examples/passport-login/package-lock.json b/examples/passport-login/package-lock.json index 2c7bc97715e2..e26781915483 100644 --- a/examples/passport-login/package-lock.json +++ b/examples/passport-login/package-lock.json @@ -1,5 +1,5 @@ { - "name": "@loopback/passport-login", + "name": "@loopback/example-passport-login", "version": "1.0.0", "lockfileVersion": 1, "requires": true, @@ -14,9 +14,9 @@ } }, "@babel/helper-validator-identifier": { - "version": "7.9.0", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.9.0.tgz", - "integrity": "sha512-6G8bQKjOh+of4PV/ThDm/rRqlU7+IGoJuofpagU5GlEl29Vv0RGqqt86ZGRV8ZuSOY3o+8yXl5y782SMcG7SHw==", + "version": "7.9.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.9.5.tgz", + "integrity": "sha512-/8arLKUFq882w4tWGj9JYzRpAlZgiWUJ+dtteNTDqrRBz9Iguck9Rn3ykuBDoUwh2TO4tSAJlrxDUOXWklJe4g==", "dev": true }, "@babel/highlight": { @@ -40,9 +40,9 @@ }, "dependencies": { "@types/node": { - "version": "13.11.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-13.11.0.tgz", - "integrity": "sha512-uM4mnmsIIPK/yeO+42F2RQhGUIs39K2RFmugcJANppXe6J1nvH87PvzPZYpza7Xhhs8Yn9yIAVdLZ84z61+0xQ==" + "version": "13.11.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-13.11.1.tgz", + "integrity": "sha512-eWQGP3qtxwL8FGneRrC5DwrJLGN4/dH1clNTuLfN81HCrxVtxRjygDTUoZJ5ASlDEeo0ppYFQjQIlXhtXpOn6g==" } } }, @@ -61,9 +61,9 @@ }, "dependencies": { "@types/node": { - "version": "13.11.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-13.11.0.tgz", - "integrity": "sha512-uM4mnmsIIPK/yeO+42F2RQhGUIs39K2RFmugcJANppXe6J1nvH87PvzPZYpza7Xhhs8Yn9yIAVdLZ84z61+0xQ==" + "version": "13.11.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-13.11.1.tgz", + "integrity": "sha512-eWQGP3qtxwL8FGneRrC5DwrJLGN4/dH1clNTuLfN81HCrxVtxRjygDTUoZJ5ASlDEeo0ppYFQjQIlXhtXpOn6g==" } } }, @@ -74,9 +74,9 @@ "dev": true }, "@types/express": { - "version": "4.17.4", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.4.tgz", - "integrity": "sha512-DO1L53rGqIDUEvOjJKmbMEQ5Z+BM2cIEPy/eV3En+s166Gz+FeuzRerxcab757u/U4v4XF4RYrZPmqKa+aY/2w==", + "version": "4.17.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.6.tgz", + "integrity": "sha512-n/mr9tZI83kd4azlPG5y997C/M4DNABK9yErhFM6hKdym4kkmd9j0vtsJyjFIwfRBxtrxZtAfGZCNRIBMFLK5w==", "requires": { "@types/body-parser": "*", "@types/express-serve-static-core": "*", @@ -85,18 +85,18 @@ } }, "@types/express-serve-static-core": { - "version": "4.17.3", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.3.tgz", - "integrity": "sha512-sHEsvEzjqN+zLbqP+8OXTipc10yH1QLR+hnr5uw29gi9AhCAAAdri8ClNV7iMdrJrIzXIQtlkPvq8tJGhj3QJQ==", + "version": "4.17.4", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.4.tgz", + "integrity": "sha512-dPs6CaRWxsfHbYDVU51VjEJaUJEcli4UI0fFMT4oWmgCvHj+j7oIxz5MLHVL0Rv++N004c21ylJNdWQvPkkb5w==", "requires": { "@types/node": "*", "@types/range-parser": "*" }, "dependencies": { "@types/node": { - "version": "13.11.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-13.11.0.tgz", - "integrity": "sha512-uM4mnmsIIPK/yeO+42F2RQhGUIs39K2RFmugcJANppXe6J1nvH87PvzPZYpza7Xhhs8Yn9yIAVdLZ84z61+0xQ==" + "version": "13.11.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-13.11.1.tgz", + "integrity": "sha512-eWQGP3qtxwL8FGneRrC5DwrJLGN4/dH1clNTuLfN81HCrxVtxRjygDTUoZJ5ASlDEeo0ppYFQjQIlXhtXpOn6g==" } } }, @@ -115,9 +115,9 @@ }, "dependencies": { "@types/node": { - "version": "13.11.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-13.11.0.tgz", - "integrity": "sha512-uM4mnmsIIPK/yeO+42F2RQhGUIs39K2RFmugcJANppXe6J1nvH87PvzPZYpza7Xhhs8Yn9yIAVdLZ84z61+0xQ==" + "version": "13.11.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-13.11.1.tgz", + "integrity": "sha512-eWQGP3qtxwL8FGneRrC5DwrJLGN4/dH1clNTuLfN81HCrxVtxRjygDTUoZJ5ASlDEeo0ppYFQjQIlXhtXpOn6g==" } } }, @@ -132,9 +132,9 @@ "integrity": "sha512-FwI9gX75FgVBJ7ywgnq/P7tw+/o1GUbtP0KzbtusLigAOgIgNISRK0ZPl4qertvXSIE8YbsVJueQ90cDt9YYyw==" }, "@types/node": { - "version": "10.17.18", - "resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.18.tgz", - "integrity": "sha512-DQ2hl/Jl3g33KuAUOcMrcAOtsbzb+y/ufakzAdeK9z/H/xsvkpbETZZbPNMIiQuk24f5ZRMCcZIViAwyFIiKmg==", + "version": "10.17.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.19.tgz", + "integrity": "sha512-46/xThm3zvvc9t9/7M3AaLEqtOpqlYYYcCZbpYVAQHG20+oMZBkae/VMrn4BTi6AJ8cpack0mEXhGiKmDNbLrQ==", "dev": true }, "@types/oauth": { @@ -146,9 +146,9 @@ }, "dependencies": { "@types/node": { - "version": "13.11.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-13.11.0.tgz", - "integrity": "sha512-uM4mnmsIIPK/yeO+42F2RQhGUIs39K2RFmugcJANppXe6J1nvH87PvzPZYpza7Xhhs8Yn9yIAVdLZ84z61+0xQ==" + "version": "13.11.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-13.11.1.tgz", + "integrity": "sha512-eWQGP3qtxwL8FGneRrC5DwrJLGN4/dH1clNTuLfN81HCrxVtxRjygDTUoZJ5ASlDEeo0ppYFQjQIlXhtXpOn6g==" } } }, @@ -397,6 +397,15 @@ "integrity": "sha512-+Ryf6g3BKoRc7jfp7ad8tM4TtMiaWvbF/1/sQcZPkkS7ag3D5nMBCe2UfOTONtAkaG0tO0ij3C5Lwmf1EiyjHg==", "dev": true }, + "axios": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.19.2.tgz", + "integrity": "sha512-fjgm5MvRHLhx+osE2xoekY70AhARk3a6hkN+3Io1jc00jtquGvxYlKlsFUhmUET0V5te6CcZI7lcv2Ym61mjHA==", + "dev": true, + "requires": { + "follow-redirects": "1.5.10" + } + }, "balanced-match": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", @@ -1079,6 +1088,26 @@ "integrity": "sha512-r5wGx7YeOwNWNlCA0wQ86zKyDLMQr+/RB8xy74M4hTphfmjlijTSSXGuH8rnvKZnfT9i+75zmd8jcKdMR4O6jA==", "dev": true }, + "follow-redirects": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.5.10.tgz", + "integrity": "sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ==", + "dev": true, + "requires": { + "debug": "=3.1.0" + }, + "dependencies": { + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + } + } + }, "forwarded": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.1.2.tgz", @@ -1685,6 +1714,16 @@ "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", "integrity": "sha1-tVOaqPwiWj0a0XlHbd8ja0QPUuQ=" }, + "path": { + "version": "0.12.7", + "resolved": "https://registry.npmjs.org/path/-/path-0.12.7.tgz", + "integrity": "sha1-1NwqUGxM4hl+tIHr/NWzbAFAsQ8=", + "dev": true, + "requires": { + "process": "^0.11.1", + "util": "^0.10.3" + } + }, "path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -1718,6 +1757,12 @@ "integrity": "sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ=", "dev": true }, + "process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha1-czIwDoQBYb2j5podHZGn1LwW8YI=", + "dev": true + }, "progress": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", @@ -2227,6 +2272,15 @@ "punycode": "^2.1.0" } }, + "util": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/util/-/util-0.10.4.tgz", + "integrity": "sha512-0Pm9hTQ3se5ll1XihRic3FDIku70C+iHUdT/W926rSgHV5QgXsYbKZN8MSC3tJtSkhuROzvsQjAaFENRXr+19A==", + "dev": true, + "requires": { + "inherits": "2.0.3" + } + }, "utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", diff --git a/examples/passport-login/package.json b/examples/passport-login/package.json index d2e21a55cb6b..67f55f82f9b9 100644 --- a/examples/passport-login/package.json +++ b/examples/passport-login/package.json @@ -1,5 +1,5 @@ { - "name": "@loopback/passport-login", + "name": "@loopback/example-passport-login", "version": "1.0.0", "description": "examples to demonstrate authentication with passport strategies", "main": "index.js", @@ -34,16 +34,17 @@ "author": "IBM Corp.", "license": "MIT", "dependencies": { - "@loopback/authentication": "^4.1.1", - "@loopback/authentication-passport": "^2.0.0", - "@loopback/boot": "2.0.2", - "@loopback/context": "3.2.0", - "@loopback/core": "2.2.0", - "@loopback/openapi-v3": "3.1.1", - "@loopback/repository": "2.0.2", - "@loopback/rest": "3.1.0", - "@loopback/security": "0.2.2", - "@loopback/service-proxy": "2.0.2", + "@loopback/authentication": "^4.1.2", + "@loopback/authentication-passport": "^2.0.3", + "@loopback/boot": "^2.0.3", + "@loopback/context": "^3.3.0", + "@loopback/core": "^2.2.1", + "@loopback/openapi-v3": "^3.1.2", + "@loopback/repository": "^2.1.0", + "@loopback/rest": "^3.2.0", + "@loopback/rest-crud": "^0.7.3", + "@loopback/security": "^0.2.3", + "@loopback/service-proxy": "^2.0.3", "@types/jsonwebtoken": "8.3.8", "@types/lodash": "^4.14.149", "@types/passport-facebook": "^2.1.9", @@ -67,18 +68,22 @@ "tslib": "^1.11.1" }, "devDependencies": { - "@loopback/build": "^5.0.0", - "@loopback/eslint-config": "^6.0.2", - "@loopback/testlab": "^2.0.2", - "@types/express": "^4.17.3", - "@types/node": "^10.17.17", - "@typescript-eslint/eslint-plugin": "^2.25.0", - "@typescript-eslint/parser": "^2.25.0", + "@loopback/build": "^5.0.1", + "@loopback/eslint-config": "^6.0.3", + "@loopback/http-caching-proxy": "^2.0.3", + "@loopback/testlab": "^3.0.0", + "@types/lodash": "^4.14.149", + "@types/node": "^10.17.19", + "@typescript-eslint/eslint-plugin": "^2.27.0", + "@typescript-eslint/parser": "^2.27.0", "eslint": "^6.8.0", "eslint-config-prettier": "^6.10.1", "eslint-plugin-eslint-plugin": "^2.2.1", - "eslint-plugin-mocha": "^6.3.0", - "typescript": "~3.8.3" + "eslint-plugin-mocha": "^6.2.2", + "lodash": "^4.17.15", + "typescript": "~3.8.3", + "axios": "^0.19.2", + "path": "^0.12.7" }, "keywords": [ "loopback", diff --git a/examples/passport-login/src/__tests__/acceptance/passport-login.acceptance.ts b/examples/passport-login/src/__tests__/acceptance/passport-login.acceptance.ts new file mode 100644 index 000000000000..63bb3df97a1c --- /dev/null +++ b/examples/passport-login/src/__tests__/acceptance/passport-login.acceptance.ts @@ -0,0 +1,354 @@ +// Copyright IBM Corp. 2019. All Rights Reserved. +// Node module: @loopback/authentication-passport +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {Client, supertest, expect} from '@loopback/testlab'; +import {MockTestOauth2SocialApp} from '@loopback/authentication-passport'; +import {ExpressServer} from '../../server'; +import {User} from '../../models'; +import {startApplication} from '../../'; +import * as url from 'url'; +import qs from 'qs'; +import * as path from 'path'; +const oauth2Providers = require(path.resolve( + __dirname, + '../../../data/oauth2-test-provider', +)); + +/** + * Scenarios to test: + * Scenario 1: Signing up as a NEW user + * User is able to create a local user profile by signing in with + * an email-id and password, if the email id is not registered already. + * + * Scenario 2. Link an external profile with a local user + * After the user signs up, user is able to link local account with an + * external profile in a social app, if the email id is same. + * + * Scenario 3. Sign Up (create a new user) via an external profile + * When a user attempts to sign up with an external profile, and the email-id + * in the profile is not registered locally, a new local account is created + */ +describe('example-passport-login acceptance test', () => { + let server: ExpressServer; + let client: Client; + + /** + * This test uses the mock social app from the @loopback/authentication-passport package, + * as oauth2 profile endpoint. + */ + before(MockTestOauth2SocialApp.startMock); + after(MockTestOauth2SocialApp.stopMock); + before(async function setupApplication() { + // eslint-disable-next-line no-invalid-this + this.timeout(6000); + server = await startApplication(oauth2Providers); + client = supertest('http://127.0.0.1:3000'); + }); + + after(async function closeApplication() { + await server.stop(); + }); + + describe('User login scenarios', () => { + let Cookie: string; + let createdUser: User; + + /** + * Scenario 1. Signing up as a NEW user + * Test case 1: sign up as a new user locally, provide email id and password + * Test case 2: login as the new user with email id + * Test case 3: logout + */ + context('Scenario 1. Signing up as a NEW user', () => { + /** + * create a local account in the loopback app with the following profile + * username: test@example.com + * email: test@example.com + */ + it('signup as new user with loopback app', async () => { + const response: supertest.Response = await client + .post('/users/signup') + .type('form') + .send({ + name: 'Test User', + email: 'test@example.com', + username: 'test@example.com', + password: 'password', + }) + .expect(302); + const redirectUrl = response.get('Location'); + expect(redirectUrl).to.equal('/login'); + }); + + it('login to loopback app', async () => { + const response: supertest.Response = await client + .post('/login_submit') + .type('form') + .send({ + email: 'test@example.com', + password: 'password', + }) + .expect(302); + const setCookie: string[] = response.get('Set-Cookie'); + if (setCookie?.length) { + Cookie = setCookie[0].split(';')[0]; + } + expect(Cookie).to.containEql('session'); + }); + + it('able to access account profile page while logged in', async () => { + await client.get('/auth/account').set('Cookie', [Cookie]).expect(200); + }); + + it('access to account profile page is denied after log out', async () => { + const response = await client + .get('/logout') + .set('Cookie', [Cookie]) + .expect(302); + /** + * replace existing cookie with cookie from logout response + */ + const setCookie: string[] = response.get('Set-Cookie'); + if (setCookie?.length) { + Cookie = setCookie[0].split(';')[0]; + } + expect(Cookie).to.containEql('session'); + await client.get('/auth/account').set('Cookie', [Cookie]).expect(401); + }); + + it('check if user was registered', async () => { + const filter = 'filter={"where":{"email": "test@example.com"}}'; + const response = await supertest('') + .get('http://localhost:3000/api/users') + .query(filter); + const users = response.body as User[]; + expect(users.length).to.eql(1); + expect(users[0].email).to.eql('test@example.com'); + createdUser = users[0]; + }); + }); + + /** + * Scenario 2. Link an external profile with a local user + * Test case 1: login via a social app profile having same email id as local user + * Test case 2: check if external profile is linked to local user + * Test case 3: logout + */ + context('Scenario 2. Link an external profile with a local user', () => { + let oauthProviderUrl: string; + let providerLoginUrl: string; + let loginPageParams: string; + let callbackToLbApp: string; + + it('call is redirected to third party authorization url', async () => { + const response = await client + .get('/api/auth/thirdparty/oauth2') + .expect(303); + oauthProviderUrl = response.get('Location'); + expect(url.parse(oauthProviderUrl).pathname).to.equal('/oauth/dialog'); + }); + + it('call to authorization url is redirected to oauth providers login page', async () => { + const response = await supertest('').get(oauthProviderUrl).expect(302); + providerLoginUrl = response.get('Location'); + loginPageParams = url.parse(providerLoginUrl).query ?? ''; + expect(url.parse(response.get('Location')).pathname).to.equal('/login'); + }); + + /** + * Sign Up via a social app with the following profile + * username: testuser + * email: test@example.com + * + * Email-id MATCHES local account + */ + it('login page redirects to authorization app callback endpoint', async () => { + const loginPageHiddenParams = qs.parse(loginPageParams); + const params = { + username: 'testuser', + password: 'xyz', + // eslint-disable-next-line @typescript-eslint/camelcase + client_id: loginPageHiddenParams.client_id, + // eslint-disable-next-line @typescript-eslint/camelcase + redirect_uri: loginPageHiddenParams.redirect_uri, + scope: loginPageHiddenParams.scope, + }; + // On successful login, the authorizing app redirects to the callback url + // HTTP status code 302 is returned to the browser + const response = await supertest('') + .post('http://localhost:9000/login_submit') + .send(qs.stringify(params)) + .expect(302); + callbackToLbApp = response.get('Location'); + expect(url.parse(callbackToLbApp).pathname).to.equal( + '/api/auth/thirdparty/oauth2/callback', + ); + }); + + it('callback url contains access code', async () => { + expect(url.parse(callbackToLbApp).query).to.containEql('code'); + }); + + it('access code can be exchanged for token', async () => { + const response = await client + .get(url.parse(callbackToLbApp).path ?? '') + .expect(302); + expect(response.get('Location')).to.equal('/auth/account'); + const setCookie: string[] = response.get('Set-Cookie'); + if (setCookie?.length) { + Cookie = setCookie[0].split(';')[0]; + } + expect(Cookie).to.containEql('session'); + }); + + it('able to access account profile page while logged in', async () => { + await client.get('/auth/account').set('Cookie', [Cookie]).expect(200); + }); + + it('access to account profile page is denied after log out', async () => { + const response = await client + .get('/logout') + .set('Cookie', [Cookie]) + .expect(302); + /** + * replace existing cookie with cookie from logout response + */ + const setCookie: string[] = response.get('Set-Cookie'); + if (setCookie?.length) { + Cookie = setCookie[0].split(';')[0]; + } + expect(Cookie).to.containEql('session'); + await client.get('/auth/account').set('Cookie', [Cookie]).expect(401); + }); + + it('check if profile is linked to existing user', async () => { + const filter = 'filter={"include":[{"relation": "profiles"}]}'; + const response = await supertest('') + .get('http://localhost:3000/api/users/' + createdUser.id) + .query(filter); + const user = response.body as User; + expect(user.profiles?.length).to.eql(1); + const profiles = user.profiles ?? []; + expect(profiles[0].profile).to.eql({ + emails: [{value: 'test@example.com'}], + }); + expect(profiles[0].provider).to.eql('custom-oauth2'); + }); + }); + + /** + * Scenario 3. Sign Up (create a new user) via an external profile + * Test case 1: login via a social app profile having an email id not in local user registry + * Test case 2: check if new user is created for external profile + * Test case 3: logout + */ + context( + 'Scenario 3. Sign Up (create a new user) via an external profile', + () => { + let oauthProviderUrl: string; + let providerLoginUrl: string; + let loginPageParams: string; + let callbackToLbApp: string; + + it('call is redirected to third party authorization url', async () => { + const response = await client + .get('/api/auth/thirdparty/oauth2') + .expect(303); + oauthProviderUrl = response.get('Location'); + expect(url.parse(oauthProviderUrl).pathname).to.equal( + '/oauth/dialog', + ); + }); + + it('call to authorization url is redirected to oauth providers login page', async () => { + const response = await supertest('') + .get(oauthProviderUrl) + .expect(302); + providerLoginUrl = response.get('Location'); + loginPageParams = url.parse(providerLoginUrl).query ?? ''; + expect(url.parse(response.get('Location')).pathname).to.equal( + '/login', + ); + }); + + /** + * Sign Up via a social app with the following profile + * username: user1 + * email: usr1@lb.com + * + * Email-id NOT registered in local accounts + */ + it('login page redirects to authorization app callback endpoint', async () => { + const loginPageHiddenParams = qs.parse(loginPageParams); + const params = { + username: 'user1', + password: 'abc', + // eslint-disable-next-line @typescript-eslint/camelcase + client_id: loginPageHiddenParams.client_id, + // eslint-disable-next-line @typescript-eslint/camelcase + redirect_uri: loginPageHiddenParams.redirect_uri, + scope: loginPageHiddenParams.scope, + }; + // On successful login, the authorizing app redirects to the callback url + // HTTP status code 302 is returned to the browser + const response = await supertest('') + .post('http://localhost:9000/login_submit') + .send(qs.stringify(params)) + .expect(302); + callbackToLbApp = response.get('Location'); + expect(url.parse(callbackToLbApp).pathname).to.equal( + '/api/auth/thirdparty/oauth2/callback', + ); + }); + + it('callback url contains access code', async () => { + expect(url.parse(callbackToLbApp).query).to.containEql('code'); + }); + + it('access code can be exchanged for token', async () => { + const response = await client + .get(url.parse(callbackToLbApp).path ?? '') + .expect(302); + expect(response.get('Location')).to.equal('/auth/account'); + const setCookie: string[] = response.get('Set-Cookie'); + if (setCookie?.length) { + Cookie = setCookie[0].split(';')[0]; + } + expect(Cookie).to.containEql('session'); + }); + + it('able to access account profile page while logged in', async () => { + await client.get('/auth/account').set('Cookie', [Cookie]).expect(200); + }); + + it('access to account profile page is denied after log out', async () => { + const response = await client + .get('/logout') + .set('Cookie', [Cookie]) + .expect(302); + /** + * replace existing cookie with cookie from logout response + */ + const setCookie: string[] = response.get('Set-Cookie'); + if (setCookie?.length) { + Cookie = setCookie[0].split(';')[0]; + } + expect(Cookie).to.containEql('session'); + await client.get('/auth/account').set('Cookie', [Cookie]).expect(401); + }); + + it('check if a new user was registered', async () => { + const filter = 'filter={"where":{"email": "usr1@lb.com"}}'; + const response = await supertest('') + .get('http://localhost:3000/api/users') + .query(filter); + const users = response.body as User[]; + expect(users.length).to.eql(1); + expect(users[0].email).to.eql('usr1@lb.com'); + }); + }, + ); + }); +}); diff --git a/examples/passport-login/src/application.ts b/examples/passport-login/src/application.ts index 0e3cf8d4bfbe..e738812895d1 100644 --- a/examples/passport-login/src/application.ts +++ b/examples/passport-login/src/application.ts @@ -8,11 +8,7 @@ import {RepositoryMixin} from '@loopback/repository'; import {RestApplication} from '@loopback/rest'; import {ServiceMixin} from '@loopback/service-proxy'; import {MySequence} from './sequence'; -import { - AuthenticationComponent, - AuthenticationBindings, -} from '@loopback/authentication'; -import {Oauth2Controller} from './controllers'; +import {AuthenticationComponent} from '@loopback/authentication'; import { FaceBookOauth2Authorization, GoogleOauth2Authorization, @@ -21,8 +17,9 @@ import { } from './authentication-strategies'; import {PassportUserIdentityService, UserServiceBindings} from './services'; import {ApplicationConfig, createBindingFromClass} from '@loopback/core'; +import {CrudRestComponent} from '@loopback/rest-crud'; -export class Oauth2LoginApplication extends BootMixin( +export class OAuth2LoginApplication extends BootMixin( ServiceMixin(RepositoryMixin(RestApplication)), ) { constructor(options: ApplicationConfig = {}) { @@ -33,8 +30,8 @@ export class Oauth2LoginApplication extends BootMixin( // Set up the custom sequence this.sequence(MySequence); - this.controller(Oauth2Controller); this.component(AuthenticationComponent); + this.component(CrudRestComponent); this.projectRoot = __dirname; // Customize @loopback/boot Booter Conventions here diff --git a/examples/passport-login/src/authentication-strategies/facebook.ts b/examples/passport-login/src/authentication-strategies/facebook.ts index 26d69e71b3af..52b684fc7825 100644 --- a/examples/passport-login/src/authentication-strategies/facebook.ts +++ b/examples/passport-login/src/authentication-strategies/facebook.ts @@ -7,7 +7,6 @@ import { asAuthStrategy, AuthenticationStrategy, UserIdentityService, - AuthenticationBindings, } from '@loopback/authentication'; import {StrategyAdapter} from '@loopback/authentication-passport'; import {Profile} from 'passport'; @@ -18,7 +17,8 @@ import {extensionFor} from '@loopback/core'; import {securityId, UserProfile} from '@loopback/security'; import {User} from '../models'; import {Request, RedirectRoute} from '@loopback/rest'; -import {PassportAuthenticationBindings} from './oauth2'; +import {PassportAuthenticationBindings} from './types'; +import {verifyFunctionFactory} from './types'; @bind( asAuthStrategy, @@ -40,7 +40,7 @@ export class FaceBookOauth2Authorization implements AuthenticationStrategy { ) { this.passportstrategy = new Strategy( facebookOptions, - this.verify.bind(this), + verifyFunctionFactory(userService).bind(this), ); this.strategy = new StrategyAdapter( this.passportstrategy, @@ -49,32 +49,6 @@ export class FaceBookOauth2Authorization implements AuthenticationStrategy { ); } - /** - * verify function for the oauth2 strategy - * - * @param accessToken - * @param refreshToken - * @param profile - * @param done - */ - verify( - accessToken: string, - refreshToken: string, - profile: Profile, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - done: (error: any, user?: any, info?: any) => void, - ) { - // look up a linked user for the profile - this.userService - .findOrCreateUser(profile) - .then((user: User) => { - done(null, user); - }) - .catch((err: Error) => { - done(err); - }); - } - /** * authenticate a request * @param request diff --git a/examples/passport-login/src/authentication-strategies/google.ts b/examples/passport-login/src/authentication-strategies/google.ts index 0690eb4602c1..8c0afbd74b99 100644 --- a/examples/passport-login/src/authentication-strategies/google.ts +++ b/examples/passport-login/src/authentication-strategies/google.ts @@ -7,7 +7,6 @@ import { asAuthStrategy, AuthenticationStrategy, UserIdentityService, - AuthenticationBindings, } from '@loopback/authentication'; import {StrategyAdapter} from '@loopback/authentication-passport'; import {Profile} from 'passport'; @@ -18,7 +17,8 @@ import {extensionFor} from '@loopback/core'; import {securityId, UserProfile} from '@loopback/security'; import {User} from '../models'; import {Request, RedirectRoute} from '@loopback/rest'; -import {PassportAuthenticationBindings} from './oauth2'; +import {PassportAuthenticationBindings} from './types'; +import {verifyFunctionFactory} from './types'; @bind( asAuthStrategy, @@ -38,7 +38,10 @@ export class GoogleOauth2Authorization implements AuthenticationStrategy { @inject('googleOAuth2Options') public googleOptions: StrategyOptions, ) { - this.passportstrategy = new Strategy(googleOptions, this.verify.bind(this)); + this.passportstrategy = new Strategy( + googleOptions, + verifyFunctionFactory(userService).bind(this), + ); this.strategy = new StrategyAdapter( this.passportstrategy, this.name, diff --git a/examples/passport-login/src/authentication-strategies/index.ts b/examples/passport-login/src/authentication-strategies/index.ts index fe6792497ad8..5614a13c0ebf 100644 --- a/examples/passport-login/src/authentication-strategies/index.ts +++ b/examples/passport-login/src/authentication-strategies/index.ts @@ -7,3 +7,4 @@ export * from './oauth2'; export * from './facebook'; export * from './google'; export * from './local'; +export * from './types'; diff --git a/examples/passport-login/src/authentication-strategies/local.ts b/examples/passport-login/src/authentication-strategies/local.ts index 68ce5a2fc63f..15d3918b6fa2 100644 --- a/examples/passport-login/src/authentication-strategies/local.ts +++ b/examples/passport-login/src/authentication-strategies/local.ts @@ -3,11 +3,7 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import { - AuthenticationStrategy, - asAuthStrategy, - AuthenticationBindings, -} from '@loopback/authentication'; +import {AuthenticationStrategy, asAuthStrategy} from '@loopback/authentication'; import {StrategyAdapter} from '@loopback/authentication-passport'; import {Request, RedirectRoute} from '@loopback/rest'; import {UserProfile, securityId} from '@loopback/security'; @@ -16,14 +12,8 @@ import {bind} from '@loopback/context'; import {Strategy, IVerifyOptions} from 'passport-local'; import {repository} from '@loopback/repository'; import {UserRepository} from '../repositories'; -import {extensionFor} from '@loopback/core'; -@bind( - asAuthStrategy, - extensionFor( - AuthenticationBindings.AUTHENTICATION_STRATEGY_EXTENSION_POINT_NAME, - ), -) +@bind(asAuthStrategy) export class LocalAuthStrategy implements AuthenticationStrategy { name = 'local'; passportstrategy: Strategy; @@ -92,12 +82,22 @@ export class LocalAuthStrategy implements AuthenticationStrategy { ], }) .then(user => { - if (!user) { - return done(new Error('User not found')); + if (!user || !user.length) { + /** + * Passport-local strategy fails authentication with third argument, + * the first argument assumes an error in the authenticating process. + */ + return done(null, null, { + message: 'User Name / Password not matching', + }); } - done(null, user); + done(null, user[0]); }) .catch(err => { + /** + * Error occurred in authenticating process. + * Does not necessarily mean an unauthorized user. + */ done(err); }); } diff --git a/examples/passport-login/src/authentication-strategies/oauth2.ts b/examples/passport-login/src/authentication-strategies/oauth2.ts index 6737c7e1cc90..2f79128656f5 100644 --- a/examples/passport-login/src/authentication-strategies/oauth2.ts +++ b/examples/passport-login/src/authentication-strategies/oauth2.ts @@ -7,7 +7,6 @@ import { AuthenticationStrategy, UserIdentityService, asAuthStrategy, - AuthenticationBindings, } from '@loopback/authentication'; import {StrategyAdapter} from '@loopback/authentication-passport'; import {Profile} from 'passport'; @@ -17,10 +16,11 @@ import {UserProfile, securityId} from '@loopback/security'; import {User} from '../models'; import {UserServiceBindings} from '../services'; import {inject, bind, extensions, Getter} from '@loopback/core'; - -export namespace PassportAuthenticationBindings { - export const OAUTH2_STRATEGY = 'passport.authentication.oauth2.strategy'; -} +import { + verifyFunctionFactory, + PassportAuthenticationBindings, + profileFunction, +} from './types'; @bind(asAuthStrategy) export class Oauth2AuthStrategy implements AuthenticationStrategy { @@ -42,11 +42,19 @@ export class Oauth2AuthStrategy implements AuthenticationStrategy { public userService: UserIdentityService, @inject('customOAuth2Options') public oauth2Options: StrategyOptions, + @inject('authentication.oauth2.profile.function', {optional: true}) + public profileFn: profileFunction, ) { /** * Create a oauth2 strategy instance for a custom provider implementation */ - this.passportstrategy = new Strategy(oauth2Options, this.verify.bind(this)); + this.passportstrategy = new Strategy( + oauth2Options, + verifyFunctionFactory(userService).bind(this), + ); + if (profileFn) { + this.passportstrategy.userProfile = profileFn; + } this.strategy = new StrategyAdapter( this.passportstrategy, this.name, diff --git a/examples/passport-login/src/authentication-strategies/types.ts b/examples/passport-login/src/authentication-strategies/types.ts new file mode 100644 index 000000000000..2f1446d993ba --- /dev/null +++ b/examples/passport-login/src/authentication-strategies/types.ts @@ -0,0 +1,79 @@ +// Copyright IBM Corp. 2020. All Rights Reserved. +// Node module: @loopback/example-passport-login +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import axios from 'axios'; +import {Profile} from 'passport'; +import {UserIdentityService} from '@loopback/authentication'; +import {User} from '../models'; + +export type profileFunction = ( + accessToken: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + done: (err?: Error | null, profile?: any) => void, +) => void; + +export type VerifyFunction = + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ( + accessToken: string, + refreshToken: string, + profile: Profile, + done: (error: any, user?: any, info?: any) => void, + ) => void; + +export namespace PassportAuthenticationBindings { + export const OAUTH2_STRATEGY = 'passport.authentication.oauth2.strategy'; +} + +export const oauth2ProfileFunction: profileFunction = ( + accessToken: string, + done, +) => { + // call the profile url in the mock authorization app with the accessToken + axios + .get('http://localhost:9000/verify?access_token=' + accessToken, { + headers: {Authorization: accessToken}, + }) + .then(response => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const profile: any = response.data; + profile.id = profile.userId; + profile.emails = [profile.email]; + profile.provider = 'custom-oauth2'; + done(null, profile); + }) + .catch(err => { + done(err); + }); +}; + +/** + * provides an appropriate verify function for oauth2 strategies + * @param accessToken + * @param refreshToken + * @param profile + * @param done + */ +export const verifyFunctionFactory = function ( + userService: UserIdentityService, +): VerifyFunction { + return function ( + accessToken: string, + refreshToken: string, + profile: Profile, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + done: (error: any, user?: any, info?: any) => void, + ) { + // look up a linked user for the profile + userService + .findOrCreateUser(profile) + .then((user: User) => { + done(null, user); + }) + .catch((err: Error) => { + done(err); + }); + }; +}; diff --git a/examples/passport-login/src/controllers/index.ts b/examples/passport-login/src/controllers/index.ts index 3fb31923d95b..974d597f1bac 100644 --- a/examples/passport-login/src/controllers/index.ts +++ b/examples/passport-login/src/controllers/index.ts @@ -1 +1,7 @@ +// Copyright IBM Corp. 2020. All Rights Reserved. +// Node module: @loopback/example-passport-login +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + export * from './oauth2.controller'; +export * from './user.controller'; diff --git a/examples/passport-login/src/controllers/user.controller.ts b/examples/passport-login/src/controllers/user.controller.ts index 52d0151fab8c..9032c728cd38 100644 --- a/examples/passport-login/src/controllers/user.controller.ts +++ b/examples/passport-login/src/controllers/user.controller.ts @@ -1,5 +1,5 @@ // Copyright IBM Corp. 2020. All Rights Reserved. -// Node module: @loopback/example-access-control-migration +// Node module: @loopback/example-passport-login // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT @@ -15,6 +15,7 @@ import {UserRepository} from '../repositories'; import {repository} from '@loopback/repository'; import {SecurityBindings, UserProfile} from '@loopback/security'; import {authenticate} from '@loopback/authentication'; +import {UserCredentialsRepository} from '../repositories/user-credentials.repository'; export type Credentials = { email: string; @@ -37,13 +38,15 @@ const CredentialsSchema = { }, }; -export class UserController { +export class UserLoginController { constructor( @repository(UserRepository) public userRepository: UserRepository, + @repository(UserCredentialsRepository) + public userCredentialsRepository: UserCredentialsRepository, ) {} - @post('/users/signup') + @post('/signup') async signup( @requestBody({ description: 'signup user locally', @@ -55,13 +58,39 @@ export class UserController { credentials: Credentials, @inject(RestBindings.Http.RESPONSE) response: Response, ) { - await this.userRepository.create({ - email: credentials.email, - username: credentials.email, - name: credentials.name, - }); - response.redirect('/login'); - return response; + let userCredentials; + try { + userCredentials = await this.userCredentialsRepository.findById( + credentials.email, + ); + } catch (err) { + if (err.code !== 'ENTITY_NOT_FOUND') { + throw err; + } + } + if (!userCredentials) { + const user = await this.userRepository.create({ + email: credentials.email, + username: credentials.email, + name: credentials.name, + }); + userCredentials = await this.userCredentialsRepository.create({ + id: credentials.email, + password: credentials.password, + userId: user.id, + }); + response.redirect('/login'); + return response; + } else { + /** + * The express app that routed the /signup call to LB App, will handle the error event. + */ + response.emit( + 'User Exists', + credentials.email + ' is already registered', + ); + return response; + } } @authenticate('local') diff --git a/examples/passport-login/src/index.ts b/examples/passport-login/src/index.ts index 093bffaacc7d..13f7cdabc308 100644 --- a/examples/passport-login/src/index.ts +++ b/examples/passport-login/src/index.ts @@ -5,12 +5,84 @@ import {ApplicationConfig} from '@loopback/core'; import {ExpressServer} from './server'; +import * as path from 'path'; +import {oauth2ProfileFunction} from './authentication-strategies'; export {ExpressServer}; -export async function main(options: ApplicationConfig = {}) { - const server = new ExpressServer(options); +/** + * Prepare server config + * @param oauth2Providers + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export async function serverConfig( + oauth2Providers: any, +): Promise { + const config = { + rest: { + port: +(process.env.PORT ?? 3000), + host: process.env.HOST, + protocol: 'http', + gracePeriodForClose: 5000, // 5 seconds + openApiSpec: { + setServersFromRequest: true, + }, + // Use the LB4 application as a route. It should not be listening. + listenOnStart: false, + }, + facebookOptions: oauth2Providers['facebook-login'], + googleOptions: oauth2Providers['google-login'], + oauth2Options: oauth2Providers['oauth2'], + }; + return config; +} + +/** + * Setup and start express server + * @param server + */ +export async function setupServer(server: ExpressServer) { + const lbApp = server.lbApp; + + lbApp.bind('datasources.config.db').to({ + name: 'db', + connector: 'memory', + localStorage: '', + file: path.resolve(__dirname, '../data/db.json'), + }); + + lbApp + .bind('authentication.oauth2.profile.function') + .to(oauth2ProfileFunction); + await server.boot(); await server.start(); +} + +/** + * Start this application + * @param oauth2Providers + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export async function startApplication( + oauth2Providers: any, +): Promise { + const config = await serverConfig(oauth2Providers); + const server = new ExpressServer(config); + await setupServer(server); + return server; +} + +/** + * run main() to start application with oauth config + */ +export async function main() { + let oauth2Providers; + if (process.env.OAUTH_PROVIDERS_LOCATION) { + oauth2Providers = require(process.env.OAUTH_PROVIDERS_LOCATION); + } else { + oauth2Providers = require('./oauth2-providers'); + } + const server: ExpressServer = await startApplication(oauth2Providers); console.log(`Server is running at ${server.url}`); } diff --git a/examples/passport-login/src/model-endpoints/index.ts b/examples/passport-login/src/model-endpoints/index.ts new file mode 100644 index 000000000000..e8963869df0f --- /dev/null +++ b/examples/passport-login/src/model-endpoints/index.ts @@ -0,0 +1 @@ +export * from './user.rest-config'; diff --git a/examples/passport-login/src/model-endpoints/user.rest-config.ts b/examples/passport-login/src/model-endpoints/user.rest-config.ts new file mode 100644 index 000000000000..93d494936297 --- /dev/null +++ b/examples/passport-login/src/model-endpoints/user.rest-config.ts @@ -0,0 +1,9 @@ +import {ModelCrudRestApiConfig} from '@loopback/rest-crud'; +import {User} from '../models/user.model'; + +module.exports = { + model: User, + pattern: 'CrudRest', + dataSource: 'db', + basePath: '/users', +}; diff --git a/examples/passport-login/src/models/user-credentials.model.ts b/examples/passport-login/src/models/user-credentials.model.ts index dc9f220c4f01..589afbb52d76 100644 --- a/examples/passport-login/src/models/user-credentials.model.ts +++ b/examples/passport-login/src/models/user-credentials.model.ts @@ -12,10 +12,10 @@ import {Entity, model, property} from '@loopback/repository'; }) export class UserCredentials extends Entity { @property({ - type: 'number', + type: 'string', id: true, }) - id: number; + id: string; @property({ type: 'string', @@ -25,11 +25,17 @@ export class UserCredentials extends Entity { @property({ type: 'number', - required: true, }) - userId: number; + userId?: number; constructor(data?: Partial) { super(data); } } + +export interface UserCredentialsRelations { + // describe navigational properties here +} + +export type UserCredentialsWithRelations = UserCredentials & + UserCredentialsRelations; diff --git a/examples/passport-login/src/models/user-identity.model.ts b/examples/passport-login/src/models/user-identity.model.ts index a349c6a47d24..08491cdeda1e 100644 --- a/examples/passport-login/src/models/user-identity.model.ts +++ b/examples/passport-login/src/models/user-identity.model.ts @@ -52,3 +52,9 @@ export class UserIdentity extends Entity { super(data); } } + +export interface UserIdentityRelations { + // describe navigational properties here +} + +export type UserIdentityWithRelations = UserIdentity & UserIdentityRelations; diff --git a/examples/passport-login/src/models/user.model.ts b/examples/passport-login/src/models/user.model.ts index e011f185795a..f8de55cde34e 100644 --- a/examples/passport-login/src/models/user.model.ts +++ b/examples/passport-login/src/models/user.model.ts @@ -31,6 +31,7 @@ export class User extends Entity { @property({ type: 'string', required: true, + index: {unique: true}, }) username: string; @@ -52,7 +53,7 @@ export class User extends Entity { verificationToken?: string; @hasOne(() => UserCredentials) - userCredentials?: UserCredentials; + credentials?: UserCredentials; @hasMany(() => UserIdentity) profiles?: UserIdentity[]; diff --git a/examples/passport-login/src/repositories/user.repository.ts b/examples/passport-login/src/repositories/user.repository.ts index 590fde4d7ae8..b8d7c1dec76f 100644 --- a/examples/passport-login/src/repositories/user.repository.ts +++ b/examples/passport-login/src/repositories/user.repository.ts @@ -8,10 +8,12 @@ import { DefaultCrudRepository, HasManyRepositoryFactory, repository, + HasOneRepositoryFactory, } from '@loopback/repository'; import {DbDataSource} from '../datasources'; -import {User, UserIdentity} from '../models'; +import {User, UserIdentity, UserCredentials} from '../models'; import {UserIdentityRepository} from './user-identity.repository'; +import {UserCredentialsRepository} from './user-credentials.repository'; export class UserRepository extends DefaultCrudRepository< User, @@ -22,10 +24,17 @@ export class UserRepository extends DefaultCrudRepository< typeof User.prototype.id >; + public readonly credentials: HasOneRepositoryFactory< + UserCredentials, + typeof User.prototype.id + >; + constructor( @inject('datasources.db') dataSource: DbDataSource, @repository.getter('UserIdentityRepository') protected profilesGetter: Getter, + @repository.getter('UserCredentialsRepository') + protected credentialsGetter: Getter, ) { super(User, dataSource); this.profiles = this.createHasManyRepositoryFactoryFor( @@ -33,5 +42,14 @@ export class UserRepository extends DefaultCrudRepository< profilesGetter, ); this.registerInclusionResolver('profiles', this.profiles.inclusionResolver); + + this.credentials = this.createHasOneRepositoryFactoryFor( + 'credentials', + credentialsGetter, + ); + this.registerInclusionResolver( + 'credentials', + this.credentials.inclusionResolver, + ); } } diff --git a/examples/passport-login/src/sequence.ts b/examples/passport-login/src/sequence.ts index 5806896dd6ad..f68c6516bd8c 100644 --- a/examples/passport-login/src/sequence.ts +++ b/examples/passport-login/src/sequence.ts @@ -20,41 +20,6 @@ import { Send, SequenceHandler, } from '@loopback/rest'; -import {StrategyOption} from 'passport-facebook'; -import {StrategyOptions} from 'passport-google-oauth2'; -import {StrategyOptions as CustomOAuth2Options} from 'passport-oauth2'; -const oauth2Providers = require('../../oauth2-providers'); - -/** - * needs improvement - * TODO: - * 1. read provider specific options from a datastore - * 2. store oauth2 provider registrations, ie, app registrations in the datastore, - * so that client_id and client_secrets can be stored securely - */ -const facebookOptions: StrategyOption = { - clientID: - process.env.FACEBOOK_APPID ?? oauth2Providers['facebook-login'].clientID, - clientSecret: oauth2Providers['facebook-login'].clientSecret, - callbackURL: oauth2Providers['facebook-login'].callbackURL, - profileFields: oauth2Providers['facebook-login'].profileFields, -}; - -const googleOptions: StrategyOptions = { - clientID: - process.env.GOOGLE_APPID ?? oauth2Providers['google-login'].clientID, - clientSecret: oauth2Providers['google-login'].clientSecret, - callbackURL: oauth2Providers['google-login'].callbackURL, - scope: oauth2Providers['google-login'].scope, -}; - -const oauth2Options: CustomOAuth2Options = { - clientID: oauth2Providers['oauth2'].clientID, - clientSecret: oauth2Providers['oauth2'].clientSecret, - callbackURL: oauth2Providers['oauth2'].callbackURL, - authorizationURL: oauth2Providers['oauth2'].authorizationURL, - tokenURL: oauth2Providers['oauth2'].tokenURL, -}; const SequenceActions = RestBindings.SequenceActions; @@ -79,17 +44,6 @@ export class MySequence implements SequenceHandler { // but in our case we need the path params to know the provider name const args = await this.parseParams(request, route); - /** - * bind the oauth2 options to request context - * - * TODO: - * bind secrets like client_id and client_secret from here, - * read secrets specific to this request from a datastore - */ - context.bind('facebookOAuth2Options').to(facebookOptions); - context.bind('googleOAuth2Options').to(googleOptions); - context.bind('customOAuth2Options').to(oauth2Options); - // if provider name is available in the request path params, set it in the query if (route.pathParams && route.pathParams.provider) { request.query['oauth2-provider-name'] = route.pathParams.provider; @@ -102,6 +56,23 @@ export class MySequence implements SequenceHandler { const result = await this.invoke(route, args); this.send(response, result); } catch (error) { + /** + * Authentication errors for login page are handled by the express app + */ + if ( + context.request.path === '/login' && + (error.status === 401 || error.name === 'UnauthorizedError') + ) { + /** + * The express app that routed the /signup call to LB App, will handle the error event. + */ + context.response.emit( + 'UnauthorizedError', + 'User Authentication Failed', + ); + return; + } + if ( error.code === AUTHENTICATION_STRATEGY_NOT_FOUND || error.code === USER_PROFILE_NOT_FOUND diff --git a/examples/passport-login/src/server.ts b/examples/passport-login/src/server.ts index 96ec09e873da..463c7ae100b4 100644 --- a/examples/passport-login/src/server.ts +++ b/examples/passport-login/src/server.ts @@ -8,34 +8,42 @@ import express from 'express'; import http from 'http'; import {AddressInfo} from 'net'; import pEvent from 'p-event'; -import {Oauth2LoginApplication} from './application'; +import {OAuth2LoginApplication} from './application'; /** - * an express server which embeds an express web app and a LB4 API server + * An express server with multiple apps * - * The LB4 API server serves to provide Oauth2 interfaces with external providers + * 1. WEB App + * a. An express web app which requires various user sign up provisions + * b. The express router is mounted in the root '/' path + * 2. LB4 API server + * a. LB4 application provides passport login services for the express app + * b. The LB4 login apis are wrapped with session middleware to allow client sessions with user profiles */ export class ExpressServer { - /** - * An express web app which requires local authentication - */ - private webApp: express.Application; - public readonly lbApp: Oauth2LoginApplication; + public webApp: express.Application; + public readonly lbApp: OAuth2LoginApplication; private server?: http.Server; public url: String; constructor(options: ApplicationConfig = {}) { // Express Web App this.webApp = require('../web-application/express-app'); - // LB4 App - this.lbApp = new Oauth2LoginApplication(options); + this.lbApp = new OAuth2LoginApplication(options); + + /** + * bind the oauth2 options to lb app + * TODO: + * 1. allow to change client_id and client_secret after application startup + * 2. allow to read oauth2 app registrations from a datastore + */ + this.lbApp.bind('facebookOAuth2Options').to(options.facebookOptions); + this.lbApp.bind('googleOAuth2Options').to(options.googleOptions); + this.lbApp.bind('customOAuth2Options').to(options.oauth2Options); /** - * Mount the LoopBack app in express: - * - * We are able to wrap the LoopBack apis with express middleware - * for saving user profiles as login sessions + * Mount the LB4 app router in /api path */ this.webApp.use('/api', this.lbApp.requestHandler); } diff --git a/examples/passport-login/src/services/user.service.ts b/examples/passport-login/src/services/user.service.ts index 126e985e7eaf..09915db02b8a 100644 --- a/examples/passport-login/src/services/user.service.ts +++ b/examples/passport-login/src/services/user.service.ts @@ -33,6 +33,7 @@ export class PassportUserIdentityService if (!profile.emails || !profile.emails.length) { throw new Error('email-id is required in returned profile to login'); } + const email = profile.emails[0].value; const users: User[] = await this.userRepository.find({ @@ -44,7 +45,7 @@ export class PassportUserIdentityService if (!users || !users.length) { user = await this.userRepository.create({ email: email, - name: profile.displayName, + name: JSON.stringify(profile.name ?? profile.displayName), username: email, }); } else { @@ -67,7 +68,10 @@ export class PassportUserIdentityService try { profile = await this.userIdentityRepository.findById(userIdentity.id); } catch (err) { - console.log(err); + // no need to throw an error if entity is not found + if (!(err.code === 'ENTITY_NOT_FOUND')) { + throw err; + } } if (!profile) { diff --git a/examples/passport-login/web-application/express-app.js b/examples/passport-login/web-application/express-app.js index 3e9f01bf87b8..71841708e3b2 100644 --- a/examples/passport-login/web-application/express-app.js +++ b/examples/passport-login/web-application/express-app.js @@ -13,6 +13,14 @@ const session = require('client-sessions'); const path = require('path'); const bodyParser = require('body-parser'); const app = (module.exports = express()); +const userLoginTemplate = require.resolve('./views/pages/login.jade'); + +/** + * save a copy of the jade login template, + * to use in 'Post /users/signup' and 'POST /login_submit' routes. + * Reasons explained in the route handler. + */ +const loginTemplate = require('jade').compileFile(userLoginTemplate); /** * use jade as view engine @@ -124,9 +132,18 @@ app.get('/signup', function (req, res, next) { /** * submit signup request */ -app.post('/signup', urlencodedParser, function (req, res, next) { - req.url = '/api/users/signup'; +app.post('/users/signup', urlencodedParser, function (req, res, next) { + req.url = '/api/signup'; req.headers['accept'] = 'text/json'; + res.on('User Exists', msg => { + res.status(401); + /** + * Sign Up events (like 'User Exists') are captured and redirected to the login page with error message. + * This helps focusing rendering concerns only in the express app. No need to add 'Jade' dependency to LB App. + */ + res.write(loginTemplate({messages: msg})); + res.end(); + }); req.app.handle(req, res, next); }); @@ -136,5 +153,14 @@ app.post('/signup', urlencodedParser, function (req, res, next) { app.post('/login_submit', urlencodedParser, function (req, res, next) { req.url = '/api/login'; req.headers['accept'] = 'text/json'; + res.on('UnauthorizedError', msg => { + res.status(401); + /** + * authentication failures are captured and redirected to the login page with error message. + * This helps focusing rendering concerns only in the express app. No need to add 'Jade' dependency to LB App. + */ + res.write(loginTemplate({messages: msg})); + res.end(); + }); req.app.handle(req, res, next); }); diff --git a/examples/passport-login/web-application/views/pages/login.jade b/examples/passport-login/web-application/views/pages/login.jade index cc1d632ac654..99a46ae9644c 100644 --- a/examples/passport-login/web-application/views/pages/login.jade +++ b/examples/passport-login/web-application/views/pages/login.jade @@ -21,3 +21,6 @@ block content input.form-control(type='password', name='password', placeholder='Password') button.btn.btn-default(type='submit') Submit br + if messages + p + span(style="color:red")= messages diff --git a/examples/passport-login/web-application/views/pages/signup.jade b/examples/passport-login/web-application/views/pages/signup.jade index 1f895a82508d..f0a44615cf2d 100644 --- a/examples/passport-login/web-application/views/pages/signup.jade +++ b/examples/passport-login/web-application/views/pages/signup.jade @@ -12,7 +12,7 @@ block content li a(href='/auth/logout') Log Out else - form(role='form', action='/api/users/signup', method='post') + form(role='form', action='/users/signup', method='post') .form-group label(for='name') Your Name input.form-control(type='text', name='name', placeholder='Enter your name') diff --git a/extensions/authentication-passport/src/__tests__/acceptance/fixtures/mock-oauth2-social-app.ts b/extensions/authentication-passport/src/__tests__/acceptance/fixtures/mock-oauth2-social-app.ts index 935c8c039d24..a160bdda683d 100644 --- a/extensions/authentication-passport/src/__tests__/acceptance/fixtures/mock-oauth2-social-app.ts +++ b/extensions/authentication-passport/src/__tests__/acceptance/fixtures/mock-oauth2-social-app.ts @@ -75,10 +75,12 @@ const registeredApps: AppRegistry = { /** * user registry */ -const users = [ +const users: MyUser[] = [ { id: '1001', username: 'user1', + firstName: 'tinker', + lastName: 'bell', password: 'abc', email: 'usr1@lb.com', signingKey: 'AZeb==', @@ -86,10 +88,21 @@ const users = [ { id: '1002', username: 'user2', + firstName: 'rosetta', + lastName: 'fawn', password: 'xyz', email: 'usr2@lb2.com', signingKey: 'BuIx=+', }, + { + id: '1003', + username: 'testuser', + firstName: 'vidia', + lastName: 'zarina', + password: 'xyz', + email: 'test@example.com', + signingKey: 'HuYa=+', + }, ]; /** @@ -118,10 +131,11 @@ async function createJwt( const jti = Math.floor(Math.random() * Math.floor(1000)); const token = jwt.sign( { + userId: user.id, jti: jti, sub: user.id, - name: user.username, - email: user.email, + name: '' + user.firstName + user.lastName, + email: {value: user.email}, iss: 'sample oauth provider', exp: Math.floor(Date.now() / 1000) + 5 * 1000, iat: Math.floor(Date.now() / 1000), @@ -233,7 +247,10 @@ app.get('/login', function (req, response) { * 4. redirects to callback url with access code */ app.post('/login_submit', urlencodedParser, async function (req, res) { - const user = findUser(req.body.username, req.body.password); + const user: MyUser | undefined = findUser( + req.body.username, + req.body.password, + ); if (user) { // get registered app const registeredApp = registeredApps[req.body.client_id]; diff --git a/extensions/authentication-passport/src/__tests__/acceptance/fixtures/user-repository.ts b/extensions/authentication-passport/src/__tests__/acceptance/fixtures/user-repository.ts index e6e1a66ba870..99da081b5c6c 100644 --- a/extensions/authentication-passport/src/__tests__/acceptance/fixtures/user-repository.ts +++ b/extensions/authentication-passport/src/__tests__/acceptance/fixtures/user-repository.ts @@ -17,6 +17,7 @@ export interface MyUser { password?: string; email?: string; token?: string; + signingKey: string; } /** @@ -61,12 +62,14 @@ const userRepository = new UserRepository({ username: 'joesmith71', firstName: 'Joseph', lastName: 'Smith', + signingKey: 'AZeb==', }, '1000': { id: '1000', username: 'simonsmith71', firstName: 'Simon', lastName: 'Smith', + signingKey: 'AZeb==', }, }); diff --git a/extensions/authentication-passport/src/index.ts b/extensions/authentication-passport/src/index.ts index c8fb9058e96f..d9e5d63d7644 100644 --- a/extensions/authentication-passport/src/index.ts +++ b/extensions/authentication-passport/src/index.ts @@ -2,6 +2,10 @@ // Node module: @loopback/authentication-passport // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT +import { + startApp, + stopApp, +} from './__tests__/acceptance/fixtures/mock-oauth2-social-app'; /** * An adapter to plug in passport based strategies to the authentication system @@ -22,3 +26,8 @@ */ export * from './strategy-adapter'; + +export namespace MockTestOauth2SocialApp { + export const startMock = startApp; + export const stopMock = stopApp; +}