├── .eslintrc ├── .gitignore ├── views ├── loginLinks.html ├── error.html └── connectionRequestEmail.html ├── i18n ├── en.json ├── sk.json ├── pt-BR.json ├── fr.json ├── es.json ├── it.json └── de.json ├── .stylelintrc ├── package.json ├── LICENSE.md ├── .github └── workflows │ └── main.yml ├── modules └── @apostrophecms │ └── user-passport-bridge │ └── index.js ├── CHANGELOG.md ├── index.js └── README.md /.eslintrc: -------------------------------------------------------------------------------- 1 | { "extends": "apostrophe" } 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore MacOS X metadata forks (fusefs) 2 | ._* 3 | package-lock.json 4 | *.DS_Store 5 | node_modules 6 | 7 | # Never commit a CSS map file, anywhere 8 | *.css.map 9 | 10 | # vim swp files 11 | .*.sw* 12 | -------------------------------------------------------------------------------- /views/loginLinks.html: -------------------------------------------------------------------------------- 1 |
9 | {{ __t('aposPassportBridge:errorSubheading', { label: data.spec.label or data.me }) }} 10 |
11 |12 | {{ __t(data.message) }} 13 |
14 |15 | {{ __t('aposPassportBridge:contactAdministrator') }} 16 |
17 | {% endblock %} 18 | -------------------------------------------------------------------------------- /i18n/sk.json: -------------------------------------------------------------------------------- 1 | { 2 | "rejected": "Vaše prihlasovacie údaje neboli akceptované, váš účet nie je priradený k tejto stránke alebo existujúci účet má rovnaké užívateľské meno alebo e-mailovú adresu.", 3 | "logInWith": "Prihláste sa pomocou {{ label }}", 4 | "errorHeading": "Pri prihlásení došlo k chybe", 5 | "errorSubheading": "Počas prihlásenia cez {{ label }} sa vyskytla chyba", 6 | "contactAdministrator": "Ak sa domnievate, že túto správu vidíte omylom, kontaktujte administrátora.", 7 | "connectionRequest": "Pripojenie {{ strategyName }} k {{ site }}" 8 | } 9 | -------------------------------------------------------------------------------- /i18n/pt-BR.json: -------------------------------------------------------------------------------- 1 | { 2 | "rejected": "Suas credenciais não foram aceitas, sua conta não está afiliada a este site, ou uma conta existente tem o mesmo nome de usuário ou endereço de e-mail.", 3 | "logInWith": "Faça login com {{ label }}", 4 | "errorHeading": "Ocorreu um erro ao fazer login", 5 | "errorSubheading": "Ocorreu um erro ao fazer login via {{ label }}", 6 | "contactAdministrator": "Se você acredita que está vendo esta mensagem por engano, entre em contato com o administrador.", 7 | "connectionRequest": "Conectando {{ strategyName }} ao {{ site }}" 8 | } 9 | -------------------------------------------------------------------------------- /views/connectionRequestEmail.html: -------------------------------------------------------------------------------- 1 | {# This is an email template #} 2 |Hello {{ data.user.title }},
4 |You are receiving this email because a request was made to connect your {{ data.strategyName }} account to {{ data.site }}.
5 |If that is your wish, please follow this link to complete the connection:
6 | 7 |8 | If you did not request to connect these two accounts, please delete and ignore this email. 9 |
10 | -------------------------------------------------------------------------------- /.stylelintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "stylelint-config-apostrophe", 3 | "rules": { 4 | "scale-unlimited/declaration-strict-value": null, 5 | "scss/at-import-partial-extension": null, 6 | "scss/at-mixin-named-arguments": null, 7 | "scss/dollar-variable-first-in-block": null, 8 | "scss/dollar-variable-pattern": null, 9 | "scss/selector-nest-combinators": null, 10 | "scss/no-duplicate-mixins": null, 11 | "property-no-vendor-prefix": [ 12 | true, 13 | { 14 | "ignoreProperties": ["appearance"] 15 | } 16 | ] 17 | } 18 | } -------------------------------------------------------------------------------- /i18n/fr.json: -------------------------------------------------------------------------------- 1 | { 2 | "rejected": "Vos identifiants n'ont pas été acceptés, votre compte n'est pas affilié à ce site, ou un compte existant a le même nom d'utilisateur ou la même adresse e-mail.", 3 | "logInWith": "Connectez-vous avec {{ label }}", 4 | "errorHeading": "Une erreur de connexion s'est produite", 5 | "errorSubheading": "Une erreur s'est produite lors de la connexion via {{ label }}", 6 | "contactAdministrator": "Si vous pensez voir ce message par erreur, veuillez contacter l'administrateur.", 7 | "connectionRequest": "Connexion de {{ strategyName }} à {{ site }}" 8 | } 9 | -------------------------------------------------------------------------------- /i18n/es.json: -------------------------------------------------------------------------------- 1 | { 2 | "rejected": "Tus credenciales no fueron aceptadas, tu cuenta no está afiliada a este sitio, o existe una cuenta con el mismo nombre de usuario o dirección de correo electrónico.", 3 | "logInWith": "Iniciar sesión con {{ label }}", 4 | "errorHeading": "Ocurrió un error de inicio de sesión", 5 | "errorSubheading": "Ocurrió un error al iniciar sesión a través de {{ label }}", 6 | "contactAdministrator": "Si crees que estás viendo este mensaje por error, por favor contacta al administrador.", 7 | "connectionRequest": "Conectando {{ strategyName }} a {{ site }}" 8 | } 9 | -------------------------------------------------------------------------------- /i18n/it.json: -------------------------------------------------------------------------------- 1 | { 2 | "rejected": "Le tue credenziali non sono state accettate, il tuo account non è affiliato a questo sito, oppure esiste già un account con lo stesso nome utente o indirizzo email.", 3 | "logInWith": "Accedi con {{ label }}", 4 | "errorHeading": "Si è verificato un errore di accesso", 5 | "errorSubheading": "Si è verificato un errore durante l'accesso tramite {{ label }}", 6 | "contactAdministrator": "Se credi di vedere questo messaggio per errore, ti preghiamo di contattare l'amministratore.", 7 | "connectionRequest": "Collegamento di {{ strategyName }} a {{ site }}" 8 | } 9 | -------------------------------------------------------------------------------- /i18n/de.json: -------------------------------------------------------------------------------- 1 | { 2 | "rejected": "Ihre Anmeldeinformationen wurden nicht akzeptiert, Ihr Konto ist nicht mit dieser Website verbunden, oder ein vorhandenes Konto hat denselben Benutzernamen oder dieselbe E-Mail-Adresse.", 3 | "logInWith": "Mit {{ label }} einloggen", 4 | "errorHeading": "Ein Anmeldefehler ist aufgetreten", 5 | "errorSubheading": "Beim Anmelden über {{ label }} ist ein Fehler aufgetreten", 6 | "contactAdministrator": "Wenn Sie glauben, dass Sie diese Nachricht irrtümlich sehen, wenden Sie sich bitte an den Administrator.", 7 | "connectionRequest": "{{ strategyName }} mit {{ site }} verbinden" 8 | } 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@apostrophecms/passport-bridge", 3 | "version": "1.6.0", 4 | "description": "Passport.js authentication for Apostrophe", 5 | "main": "index.js", 6 | "scripts": { 7 | "lint": "npm run eslint", 8 | "eslint": "eslint .", 9 | "test": "npm run lint" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/apostrophecms/passport-bridge.git" 14 | }, 15 | "homepage": "https://github.com/apostrophecms/passport-bridge#readme", 16 | "author": "Apostrophe Technologies", 17 | "license": "MIT", 18 | "devDependencies": { 19 | "eslint-config-apostrophe": "^5.0.0", 20 | "stylelint": "^16.9.0", 21 | "stylelint-config-apostrophe": "^4.1.0" 22 | }, 23 | "dependencies": { 24 | "humanname": "^0.2.2", 25 | "klona": "^2.0.6", 26 | "passport-oauth2-refresh": "^2.2.0" 27 | } 28 | } -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021 Apostrophe Technologies, Inc. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: tests 4 | 5 | # Controls when the action will run. 6 | on: 7 | push: 8 | branches: ['main'] 9 | pull_request: 10 | branches: ['*'] 11 | 12 | # Allows you to run this workflow manually from the Actions tab 13 | workflow_dispatch: 14 | 15 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 16 | jobs: 17 | # This workflow contains a single job called "build" 18 | build: 19 | # The type of runner that the job will run on 20 | runs-on: ubuntu-latest 21 | strategy: 22 | matrix: 23 | node-version: [18, 20, 22] 24 | mongodb-version: [6.0, 7.0, 8.0] 25 | 26 | # Steps represent a sequence of tasks that will be executed as part of the job 27 | steps: 28 | - name: Git checkout 29 | uses: actions/checkout@v4 30 | 31 | - name: Use Node.js ${{ matrix.node-version }} 32 | uses: actions/setup-node@v4 33 | with: 34 | node-version: ${{ matrix.node-version }} 35 | 36 | - name: Start MongoDB 37 | uses: supercharge/mongodb-github-action@1.11.0 38 | with: 39 | mongodb-version: ${{ matrix.mongodb-version }} 40 | 41 | - run: npm install 42 | 43 | - run: npm test 44 | env: 45 | CI: true 46 | -------------------------------------------------------------------------------- /modules/@apostrophecms/user-passport-bridge/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | improve: '@apostrophecms/user', 3 | methods(self) { 4 | return { 5 | // Resolves to `{ accessToken, refreshToken }`, or `null` if 6 | // none are available for the given passport strategy. 7 | async getTokens(user, strategy) { 8 | if ((!user) || (!user._id)) { 9 | throw self.apos.error('error', 'First argument must be an apostrophe user object'); 10 | } 11 | if (!strategy) { 12 | throw self.apos.error('error', 'Second argument must be a passport strategy name'); 13 | } 14 | const info = await self.safe.findOne({ 15 | _id: user._id 16 | }); 17 | if (!info) { 18 | // Should never happen 19 | throw self.apos.error('error', 'User has no entry in the safe'); 20 | } 21 | return info?.tokens?.[strategy] || null; 22 | }, 23 | async updateTokens(user, strategy, { accessToken, refreshToken }) { 24 | await self.safe.updateOne({ 25 | _id: user._id 26 | }, { 27 | $set: { 28 | [`tokens.${strategy}`]: { 29 | accessToken, 30 | refreshToken 31 | } 32 | } 33 | }); 34 | }, 35 | async refreshTokens(user, strategy, refreshToken) { 36 | const originalRefreshToken = refreshToken; 37 | if (!refreshToken) { 38 | ({ refreshToken } = await self.getTokens(user, strategy)); 39 | } 40 | const refresh = self.apos.modules['@apostrophecms/passport-bridge'].refresh; 41 | return new Promise((resolve, reject) => { 42 | return refresh.requestNewAccessToken( 43 | strategy, 44 | refreshToken, 45 | async (err, accessToken, refreshToken) => { 46 | if (err) { 47 | return reject(err); 48 | } 49 | const newRefreshToken = refreshToken || originalRefreshToken; 50 | try { 51 | await self.updateTokens(user, strategy, { 52 | accessToken, 53 | refreshToken: newRefreshToken 54 | }); 55 | } catch (e) { 56 | return reject(e); 57 | } 58 | return resolve({ 59 | accessToken, 60 | refreshToken: newRefreshToken 61 | }); 62 | } 63 | ); 64 | }); 65 | }, 66 | async withAccessToken(user, strategy, fn) { 67 | let accessToken, refreshToken; 68 | try { 69 | const tokens = await self.getTokens(user, strategy); 70 | if (!tokens) { 71 | throw self.apos.error('notfound'); 72 | } 73 | ({ accessToken, refreshToken } = tokens); 74 | // We need "return await" because we want to catch async errors 75 | return await fn(accessToken); 76 | } catch (e) { 77 | if (e.status && e.status === 401) { 78 | const { 79 | accessToken 80 | } = await self.refreshTokens(user, strategy, refreshToken); 81 | // On the second try, failure is failure 82 | // We don't need "await" because we are already returning 83 | // a promise 84 | return fn(accessToken); 85 | } else { 86 | // Unrelated error 87 | throw e; 88 | } 89 | } 90 | }, 91 | 92 | async requestConnection(req, strategyName, options = {}) { 93 | if (!req.user) { 94 | throw self.apos.error('forbidden', 'No user'); 95 | } 96 | const bridge = self.apos.modules['@apostrophecms/passport-bridge']; 97 | const strategy = bridge.strategies[strategyName]; 98 | if (!strategy) { 99 | throw self.apos.error('notfound', 'No such strategy'); 100 | } 101 | const token = self.apos.util.generateId(); 102 | await self.safe.updateOne({ 103 | _id: req.user._id 104 | }, { 105 | $set: { 106 | [`connectionRequests.${strategyName}`]: { 107 | token, 108 | expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24), 109 | options 110 | } 111 | } 112 | }); 113 | const url = bridge.getConnectUrl(strategyName, token, true); 114 | const site = (new URL(self.apos.baseUrl)).hostname; 115 | 116 | await bridge.email(req, 117 | 'connectionRequestEmail', 118 | { 119 | site, 120 | strategyName, 121 | url, 122 | user: req.user 123 | 124 | }, { 125 | to: req.user.email, 126 | subject: req.t('aposPassportBridge:connectionRequest', { 127 | strategyName, 128 | site 129 | }) 130 | } 131 | ); 132 | } 133 | }; 134 | } 135 | }; 136 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 1.6.0 (2025-10-30) 4 | 5 | ### Adds 6 | 7 | * Uses core login `normalizeLoginName` method to lowercase username and email in case project login option `caseInsensitive` is set to true. 8 | 9 | ## 1.5.2 (2025-08-27) 10 | 11 | * Fixed regression introduced in 1.5.0-beta.1 that made it more difficult to see the logs regarding certain types of login failures and account creation issues. This issue was particularly likely to occur with strategies that do not supply `req`. 12 | * Works correctly out of the box when only `email` is available in the profile, e.g. when the user's full name or username is not available, as can happen when an idP is configured to provide an absolute minimum of information. 13 | 14 | ## 1.5.1 (2025-08-06) 15 | 16 | * README changes only. 17 | 18 | ## 1.5.0 (2025-07-09) 19 | 20 | * See 1.5.0-beta.1, below. Those changes are now official in 1.5.0. 21 | 22 | ## 1.5.0-beta.1 (2025-07-03) 23 | 24 | ### Adds 25 | 26 | * The new `factory` option allows developers to pass an async function that returns a fully initialized passport strategy object. This allows developers to solve many exactly problems as they see fit, in one single place: taking care of discovery before initialization, initializing strategies that won't accept a plain object of parameters, remapping `verify` parameters, and remapping profile properties. 27 | * Structured logging has been added, helping to debug in many situations. The debug log level is used, so by default it won't clutter the logs in production. 28 | * Logic intended to automatically refresh access tokens is no longer invoked for non-oauth strategies that can't support it. 29 | 30 | ## 1.4.0 (2025-04-16) 31 | 32 | ### Adds 33 | 34 | * Adds strategy option `verify`. `verify` is a function that accepts `findOrCreateUser` and returns a function 35 | The default `findOrCreateUser` method returns a function that accepts 5 parameters `req` plus `accessToken`, `refreshToken`, `profile` and `callback`. 36 | This is the default for some strategies like `passport-oauth2`, `passport-github2` and `passport-gitlab2`. 37 | If the passport strategy you're using have a different set of parameters outside of `req` (for example `passport-auth0`), please use the `verify` options. 38 | More info at [Customizing call to the strategy verify method](/#customizing-call-to-the-strategy-verify-method) 39 | * Add `self.specs` with the computed strategies options. 40 | 41 | ### Fixes 42 | 43 | * Fix infinite loop issue with `findOrCreateUser` without `req` parameter. 44 | * Fixed ESM support by removing `self.apos.root.import` usage. 45 | 46 | ### Changes 47 | 48 | * Bumbs `eslint-config-apostrophe` to `5`, fixes errors, removes unused dependencies. 49 | 50 | ## 1.3.0 (2024-10-31) 51 | 52 | * Use `self.apos.root.import` instead of `self.apos.root.require`. 53 | * `enablePassportStrategies` is now async. 54 | 55 | ## 1.2.1 (2024-10-03) 56 | 57 | * Adds translation strings. 58 | 59 | ## 1.2.0 - 2023-06-08 60 | 61 | * Support for making "connections" to secondary accounts. For instance, a user whose primary account login method is email can connect 62 | their account to a github account when the appropriate features are active as described in the documentation. 63 | * Accept `scope` either as an `option` of the strategy, or as an `authenticate` property for the strategy, and 64 | pass it on to the strategy in both ways, as well as to both the login and callback routes. This allows `passport-github2` 65 | to capture the user's private email address correctly, and should help with other differences between strategies as well. 66 | * Back to using upstream `passport-oauth2-refresh` now that our PR has been accepted (thanks). 67 | 68 | ## 1.2.0-alpha.4 - 2023-04-07 69 | 70 | * More dependency games. 71 | 72 | ## 1.2.0-alpha.3 - 2023-04-07 73 | 74 | * Depend on a compatible temporary fork of `passport-oauth2-refresh`. 75 | 76 | ## 1.2.0-alpha.2 - 2023-04-07 77 | 78 | * Introduced the new `retainAccessToken` option, which retains tokens in Apostrophe's 79 | "safe" where they can be used for longer than a single Apostrophe session. Please note 80 | that `retainAccessTokenInSession` is now deprecated, as it cannot work with Passport 0.6 81 | as found in current Apostrophe 3.x due to upstream changes. See the README for 82 | more information about the new approach. You only need this option if you want to 83 | call additional APIs of the provider, for instance github APIs for those using 84 | `passport-github`. 85 | * Introduced convenience methods to use the access token in such a way that it is 86 | automatically refreshed if necessary. 87 | 88 | ## 1.1.1 - 2023-02-14 89 | 90 | * Corrected a bug that prevented `retainAccessTokenInSession` from working properly. Note that this option can only work with Passport strategies that honor the `passReqToCallback: true` option (passed for you automatically). Strategies derived from `passport-oauth2`, such as `passport-github` and many others, support this and others may as well. 91 | 92 | ## 1.1.0 - 2023-02-01 93 | 94 | Setting the `retainAccessTokenInSession` option to `true` retains the `accessToken` and `refreshToken` provided by passport in `req.session.accessToken` and `req.session.refreshToken`. Depending on your oauth authentication scope, this makes it possible to carry out API calls on the user's behalf when authenticating with github, gmail, etc. If you need to refresh the access token, you might try the [passport-oauth2-refresh](https://www.npmjs.com/package/passport-oauth2-refresh) module. 95 | 96 | ## 1.0.0 - 2023-01-16 97 | 98 | Declared stable. No code changes. 99 | 100 | ## 1.0.0-beta - 2022-01-06 101 | 102 | Initial release for A3. Tested and working with Google and Okta. Other standard passport modules should also work, especially those based on OpenAuth. 103 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const humanname = require('humanname'); 4 | const { klona } = require('klona'); 5 | const { AuthTokenRefresh } = require('passport-oauth2-refresh'); 6 | 7 | module.exports = { 8 | bundle: { 9 | directory: 'modules', 10 | modules: getBundleModuleNames() 11 | }, 12 | async init(self) { 13 | await self.enablePassportStrategies(); 14 | }, 15 | options: { 16 | i18n: { 17 | ns: 'aposPassportBridge' 18 | }, 19 | create: undefined, // { role: 'guest' } 20 | retainAccessTokenInSession: false, // Legacy, incompatible with Passport 0.6 21 | retainAccessToken: false 22 | }, 23 | methods(self) { 24 | return { 25 | async enablePassportStrategies() { 26 | self.refresh = new AuthTokenRefresh(); 27 | self.specs = {}; 28 | self.strategies = {}; 29 | if (!self.apos.baseUrl) { 30 | throw new Error('@apostrophecms/passport-bridge: you must configure the top-level "baseUrl" option for apostrophe'); 31 | } 32 | if (!Array.isArray(self.options.strategies)) { 33 | throw new Error('@apostrophecms/passport-bridge: you must configure the "strategies" option'); 34 | } 35 | 36 | for (let spec of self.options.strategies) { 37 | spec = klona(spec); 38 | // Works with npm modules that export the strategy directly, npm modules 39 | // that export a Strategy property, and directly passing in a strategy property 40 | // in the spec 41 | const strategyModule = spec.module && await import(spec.module); 42 | 43 | const factory = spec.factory || ((...args) => { 44 | const Strategy = strategyModule 45 | ? (strategyModule.Strategy || strategyModule) 46 | : spec.Strategy; 47 | if (!Strategy) { 48 | throw new Error('@apostrophecms/passport-bridge: each strategy must have a "module" setting\n' + 49 | 'giving the name of an npm module installed in your project such\n' + 50 | 'as passport-oauth2, passport-oauth or a subclass with a compatible\n' + 51 | 'interface, such as passport-gitlab2, passport-twitter, etc.\n\n' + 52 | 'You may instead pass a "factory" async function that takes the configuration and\n' + 53 | 'returns a strategy object.\n\nFinally, for bc, you may pass a strategy constructor as a\n' + 54 | 'Strategy property.\n\nThe factory function is the most flexible option.' 55 | ); 56 | } 57 | return new Strategy(...args); 58 | }); 59 | 60 | // Are there strategies requiring no options? Probably not, but maybe... 61 | spec.options = spec.options || {}; 62 | const scope = spec.options.scope || spec?.authenticate?.scope; 63 | spec.options.scope = spec?.authenticate?.scope; 64 | spec.authenticate = spec.authenticate || {}; 65 | spec.authenticate.scope = spec.authenticate.scope || scope; 66 | 67 | // Must be a function that accepts self.findOrCreateUser and 68 | // returns an async function that calls self.findOrCreateUser with 5 parameters 69 | // (findOrCreateUser) => 70 | // async (req, accessToken, refreshToken, profile, callback) => 71 | // findOrCreateUser(req, accessToken, refreshToken, profile, callback) 72 | // 73 | // If there is no req, you can pass null instead 74 | // (findOrCreateUser) => 75 | // async (accessToken, refreshToken, profile, callback) => 76 | // findOrCreateUser(null, accessToken, refreshToken, profile, callback) 77 | // 78 | // You can also remap parameters 79 | // (findOrCreateUser) => 80 | // async (req, accessToken, refreshToken, extraParams, profile, callback) => 81 | // findOrCreateUser(req, accessToken, refreshToken, profile, callback) 82 | const { verify = (findOrCreateUser) => findOrCreateUser } = spec.options; 83 | 84 | if (!spec.name) { 85 | // It's hard to find the strategy name; it's not the same 86 | // as the npm name. And we need it to build the callback URL 87 | // sensibly. But we can do it by making a dummy strategy object now 88 | const dummy = await factory({ 89 | callbackURL: 'https://dummy.localhost/test', 90 | passReqToCallback: true, 91 | ...spec.options 92 | }, verify(self.findOrCreateUser(spec))); 93 | spec.name = dummy.name; 94 | } 95 | spec.label = spec.label || spec.name; 96 | spec.options.callbackURL = self.getCallbackUrl(spec, true); 97 | self.specs[spec.name] = spec; 98 | const strategy = await factory({ 99 | passReqToCallback: true, 100 | ...spec.options 101 | }, verify(self.findOrCreateUser(spec))); 102 | self.strategies[spec.name] = strategy; 103 | self.apos.login.passport.use(strategy); 104 | if (strategy._oauth2) { 105 | // This will only work with strategies that actually have an _oauth2 object 106 | self.refresh.use(self.strategies[spec.name]); 107 | } 108 | }; 109 | }, 110 | 111 | // Returns the oauth2 callback URL, which must match the route 112 | // established by `addCallbackRoute`. If `absolute` is true 113 | // then `baseUrl` and `apos.prefix` are prepended, otherwise 114 | // not (because `app.get` automatically prepends a prefix). 115 | // If the callback URL was preconfigured via spec.options.callbackURL 116 | // it is returned as-is when `absolute` is true, otherwise 117 | // the pathname is returned with any `apos.prefix` removed 118 | // to avoid adding it twice in `app.get` calls. 119 | getCallbackUrl(spec, absolute) { 120 | let url; 121 | if (spec.options && spec.options.callbackURL) { 122 | url = spec.options.callbackURL; 123 | if (absolute) { 124 | return url; 125 | } 126 | const parsed = new URL(url); 127 | url = parsed.pathname; 128 | if (self.apos.prefix) { 129 | // Remove the prefix if present, so that app.get doesn't 130 | // add it redundantly 131 | return url.replace(new RegExp('^' + self.apos.util.regExpQuote(self.apos.prefix)), ''); 132 | } 133 | return parsed.pathname; 134 | } 135 | return (absolute ? (self.apos.baseUrl + self.apos.prefix) : '') + '/auth/' + spec.name + '/callback'; 136 | }, 137 | 138 | // Returns the URL you should link users to in order for them 139 | // to log in. If `absolute` is true then `baseUrl` and `apos.prefix` 140 | // are prepended, otherwise not (because `app.get` automatically prepends a prefix). 141 | getLoginUrl(spec, absolute) { 142 | return (absolute ? (self.apos.baseUrl + self.apos.prefix) : '') + '/auth/' + spec.name + '/login'; 143 | }, 144 | 145 | // Returns the URL used to confirm a connection to another service. 146 | // Since this is used in email `absolute` is usually `true`, however 147 | // it is also used to create routes. 148 | getConnectUrl(strategyName, token, absolute) { 149 | return (absolute ? (self.apos.baseUrl + self.apos.prefix) : '') + '/auth/' + strategyName + '/connect/' + token; 150 | }, 151 | 152 | // Adds the login route 153 | // which will be `/auth/strategyname/login`, where the strategy name 154 | // depends on the passport module being used. 155 | // 156 | // Redirect users to this URL 157 | // to start the process of logging them in via each strategy 158 | addLoginRoute(spec) { 159 | self.apos.app.get(self.getLoginUrl(spec), (req, res, next) => { 160 | if (req.query.newLocale) { 161 | req.session.passportLocale = { 162 | oldLocale: req.query.oldLocale, 163 | newLocale: req.query.newLocale, 164 | oldAposDocId: req.query.oldAposDocId 165 | }; 166 | return res.redirect(self.apos.url.build(req.url, { 167 | newLocale: null, 168 | oldLocale: null, 169 | oldAposDocId: null 170 | })); 171 | } else { 172 | return next(); 173 | } 174 | }, self.apos.login.passport.authenticate(spec.name, spec.authenticate)); 175 | }, 176 | 177 | addConnectRoute(spec) { 178 | self.apos.app.get(self.getConnectUrl(spec.name, ':token'), async (req, res) => { 179 | const strategyName = spec.name; 180 | try { 181 | const token = req.params.token; 182 | if (!token.length) { 183 | self.apos.util.info('No token provided to connect route'); 184 | return res.redirect(self.getFailureUrl(spec)); 185 | } 186 | const safe = await self.apos.user.safe.findOne({ 187 | [`connectionRequests.${strategyName}.token`]: token 188 | }); 189 | if (!safe) { 190 | self.apos.util.info('Token not found for connect route'); 191 | return res.redirect(self.getFailureUrl(spec)); 192 | } 193 | const request = safe.connectionRequests[strategyName]; 194 | if (request.expiresAt < Date.now()) { 195 | self.apos.util.info('Token expired for connect route'); 196 | return res.redirect(self.getFailureUrl(spec)); 197 | } 198 | const nonce = self.apos.util.generateId(); 199 | await self.apos.user.safe.updateOne({ 200 | _id: safe._id 201 | }, { 202 | $set: { 203 | [`connectionRequests.${strategyName}`]: { 204 | nonce, 205 | session: { 206 | ...req.session 207 | } 208 | } 209 | } 210 | }); 211 | res.cookie('apos-connect', `${strategyName}:${nonce}`, { 212 | maxAge: 1000 * 60 * 60 * 24, 213 | httpOnly: true, 214 | secure: (req.protocol === 'https') 215 | }); 216 | return res.redirect(self.getLoginUrl(spec)); 217 | } catch (e) { 218 | self.apos.util.error(e); 219 | return res.redirect(self.getFailureUrl(spec)); 220 | } 221 | }); 222 | }, 223 | 224 | // Adds the callback route associated with a strategy. oauth-based strategies and 225 | // certain others redirect here to complete the login handshake 226 | addCallbackRoute(spec) { 227 | self.apos.app.get(self.getCallbackUrl(spec, false), 228 | // middleware 229 | self.apos.login.passport.authenticate( 230 | spec.name, 231 | { 232 | ...spec.authenticate, 233 | failureRedirect: self.getFailureUrl(spec) 234 | } 235 | ), 236 | // The actual route reached after authentication redirects 237 | // appropriately, either to an explicitly requested location 238 | // or the home page 239 | (req, res) => { 240 | const redirect = req.session.passportRedirect || '/'; 241 | delete req.session.passportRedirect; 242 | return res.rawRedirect(redirect); 243 | } 244 | ); 245 | }, 246 | 247 | addFailureRoute(spec) { 248 | self.apos.app.get(self.getFailureUrl(spec), function (req, res) { 249 | // Gets i18n'd in the template 250 | return self.sendPage(req, 'error', { 251 | spec, 252 | message: 'aposPassportBridge:rejected' 253 | }); 254 | }); 255 | }, 256 | 257 | getFailureUrl(spec) { 258 | return '/auth/' + spec.name + '/error'; 259 | }, 260 | 261 | // Given a strategy spec from the configuration, return 262 | // an oauth passport callback function to find the user based 263 | // on the profile, creating them if appropriate. 264 | 265 | findOrCreateUser(spec) { 266 | return body; 267 | async function body(req, accessToken, refreshToken, profile, callback) { 268 | if (req !== null && !req?.res) { 269 | // req was not passed (strategy used does not support that), shift 270 | // parameters by one so they come in under the right names 271 | return body(null, req, accessToken, refreshToken, profile); 272 | } 273 | // Always use an admin req to find the user 274 | const adminReq = self.apos.task.getReq(); 275 | let criteria = {}; 276 | 277 | if (spec.accept) { 278 | if (!spec.accept(profile)) { 279 | self.logDebug('rejectedProfile', { 280 | strategyName: spec.name, 281 | profile 282 | }); 283 | return callback(null, false); 284 | } 285 | } 286 | 287 | const connectingUserId = req && await self.getConnectingUserId(req, spec.name); 288 | if (connectingUserId) { 289 | criteria._id = connectingUserId; 290 | } else { 291 | const emails = self.getRelevantEmailsFromProfile(spec, profile); 292 | if (spec.emailDomain && (!emails.length)) { 293 | // Email domain filter is in effect and user has no emails or 294 | // only emails in the wrong domain 295 | self.logDebug('noPermittedEmailAddress', { 296 | strategyName: spec.name, 297 | requiredEmailDomain: spec.emailDomain, 298 | profile 299 | }); 300 | return callback(null, false); 301 | } 302 | if (typeof (spec.match) === 'function') { 303 | criteria = spec.match(profile); 304 | } else { 305 | switch (spec.match || 'username') { 306 | case 'id': 307 | if (!profile.id) { 308 | self.apos.util.error('@apostrophecms/passport-bridge: profile has no id. You probably want to set the "match" option for this strategy to "username" or "email".'); 309 | return callback(null, false); 310 | } 311 | criteria[spec.name + 'Id'] = profile.id; 312 | break; 313 | case 'username': 314 | if (!profile.username) { 315 | self.apos.util.error('@apostrophecms/passport-bridge: profile has no username. You probably want to set the "match" option for this strategy to "id" or "email".'); 316 | return callback(null, false); 317 | } 318 | criteria.username = self.apos.login.normalizeLoginName( 319 | profile.username 320 | ); 321 | break; 322 | case 'email': 323 | case 'emails': 324 | if (!emails.length) { 325 | // User has no email 326 | self.logDebug('noEmailAndEmailIsId', { 327 | strategyName: spec.name, 328 | profile 329 | }); 330 | return callback(null, false); 331 | } 332 | criteria.$or = emails.map(email => { 333 | return { 334 | email: self.apos.login.normalizeLoginName(email) 335 | }; 336 | }); 337 | break; 338 | default: 339 | return callback(new Error(`@apostrophecms/passport-bridge: ${spec.match} is not a supported value for the match property`)); 340 | } 341 | } 342 | } 343 | criteria.disabled = { $ne: true }; 344 | if ((!connectingUserId) && (spec.login === false)) { 345 | // Some strategies are only for connecting, not logging in 346 | self.logDebug('strategyNotForLogin', { 347 | strategyName: spec.name, 348 | profile 349 | }); 350 | return callback(null, false); 351 | } 352 | try { 353 | let user; 354 | const foundUser = await self.apos.user.find(adminReq, criteria).toObject(); 355 | if (foundUser) { 356 | self.logDebug('userFound', { 357 | strategyName: spec.name, 358 | profile, 359 | foundUser 360 | }); 361 | user = foundUser; 362 | } 363 | if (!foundUser && self.options.create && !connectingUserId) { 364 | const createdUser = await self.createUser(spec, profile); 365 | self.logDebug('userCreated', { 366 | strategyName: spec.name, 367 | profile, 368 | createdUser 369 | }); 370 | user = createdUser; 371 | } 372 | // Legacy, incompatible with Passport 0.6 373 | if (self.options.retainAccessTokenInSession && user && req) { 374 | req.session.accessToken = accessToken; 375 | req.session.refreshToken = refreshToken; 376 | } 377 | // Preferred, see documentation 378 | if (self.options.retainAccessToken && user) { 379 | await self.apos.user.safe.updateOne({ 380 | _id: user._id 381 | }, { 382 | $set: { 383 | [`tokens.${spec.name}.accessToken`]: accessToken, 384 | [`tokens.${spec.name}.refreshToken`]: refreshToken 385 | } 386 | }); 387 | } 388 | if (user) { 389 | await self.apos.doc.db.updateOne({ 390 | _id: user._id 391 | }, { 392 | $set: { 393 | [`${spec.name}Id`]: profile.id 394 | } 395 | }); 396 | } 397 | if (!user) { 398 | self.logDebug('noUserFound', { 399 | strategyName: spec.name, 400 | profile 401 | }); 402 | } else { 403 | self.logDebug('findOrCreateUserSuccessful', { 404 | strategyName: spec.name, 405 | profile, 406 | user 407 | }); 408 | } 409 | return callback(null, user || false); 410 | } catch (err) { 411 | self.apos.util.error(err); 412 | return callback(err); 413 | } 414 | }; 415 | }, 416 | 417 | async getConnectingUserId(req, strategyName) { 418 | const info = await self.getConnectingInfo(req); 419 | if (strategyName && info?.strategyName !== strategyName) { 420 | return false; 421 | } 422 | return info && info._id; 423 | }, 424 | 425 | async getConnectingSession(req, strategyName) { 426 | const info = await self.getConnectingInfo(req); 427 | if (strategyName && info?.strategyName !== strategyName) { 428 | return false; 429 | } 430 | return info && info.session; 431 | }, 432 | 433 | async getConnectingInfo(req) { 434 | const cookie = req.cookies['apos-connect']; 435 | if (!cookie) { 436 | return null; 437 | } 438 | const [ strategyName, nonce ] = cookie.split(':'); 439 | if (!(strategyName && nonce)) { 440 | return null; 441 | } 442 | const safe = await self.apos.user.safe.findOne({ 443 | [`connectionRequests.${strategyName}.nonce`]: nonce 444 | }); 445 | if (!safe) { 446 | return null; 447 | } 448 | if (safe.connectionRequests[strategyName].expiresAt < Date.now()) { 449 | return null; 450 | } 451 | return { 452 | _id: safe._id, 453 | session: safe.connectionRequests[strategyName].session, 454 | strategyName 455 | }; 456 | }, 457 | 458 | // Returns an array of email addresses found in the user's 459 | // profile, via profile.emails[n].value, profile.emails[n] (a string), 460 | // or profile.email. Passport strategies usually normalize 461 | // to the first of the three. 462 | getRelevantEmailsFromProfile(spec, profile) { 463 | let emails = []; 464 | if (Array.isArray(profile.emails) && profile.emails.length) { 465 | (profile.emails || []).forEach(email => { 466 | if (typeof (email) === 'string') { 467 | // maybe someone does this as simple strings... 468 | emails.push(email); 469 | // but google does it as objects with value properties 470 | } else if (email && email.value) { 471 | emails.push(email.value); 472 | } 473 | }); 474 | } else if (profile.email) { 475 | emails.push(profile.email); 476 | } 477 | if (spec.emailDomain) { 478 | emails = emails.filter(email => { 479 | const endsWith = '@' + spec.emailDomain; 480 | return email.substr(email.length - endsWith.length) === endsWith; 481 | }); 482 | } 483 | return emails; 484 | }, 485 | 486 | // Create a new user based on a profile. This occurs only 487 | // if the "create" option is set and a user arrives who has 488 | // a valid passport profile but does not exist in the local database. 489 | async createUser(spec, profile) { 490 | const user = self.apos.user.newInstance(); 491 | user.role = await self.userRole(); 492 | user.username = self.apos.login.normalizeLoginName(profile.username); 493 | user[spec.name + 'Id'] = profile.id; 494 | const [ email ] = self.getRelevantEmailsFromProfile(spec, profile); 495 | if (email) { 496 | user.email = self.apos.login.normalizeLoginName(email); 497 | } 498 | // Try hard to come up with a title, as without a slug we'll get an error 499 | // at insert time 500 | user.title = profile.displayName || profile.username || email || ''; 501 | user.username = user.username || user.email || self.apos.util.slugify(user.title); 502 | if (profile.name) { 503 | user.firstName = profile.name.givenName; 504 | if (profile.name.middleName) { 505 | user.firstName += ' ' + profile.name.middleName; 506 | } 507 | user.lastName = profile.name.familyName; 508 | } else if (profile.firstName || profile.lastName) { 509 | user.firstName = profile.firstName; 510 | user.lastName = profile.lastName; 511 | } else if (profile.displayName) { 512 | const parsedName = humanname.parse(profile.displayName); 513 | user.firstName = parsedName.firstName; 514 | user.lastName = parsedName.lastName; 515 | } 516 | const req = self.apos.task.getReq(); 517 | if (spec.import) { 518 | // Allow for specialized import of more fields 519 | spec.import(profile, user); 520 | } 521 | await self.apos.user.insert(req, user); 522 | return user; 523 | }, 524 | 525 | // Overridable method for determining the default role 526 | // of newly created users. 527 | async userRole() { 528 | return (self.options.create && self.options.create.role) || 'guest'; 529 | } 530 | }; 531 | }, 532 | handlers(self) { 533 | return { 534 | '@apostrophecms/login:afterSessionLogin': { 535 | async restoreConnectionSession(req) { 536 | const session = await self.getConnectingSession(req); 537 | if (session) { 538 | for (const [ key, value ] of Object.entries(session)) { 539 | req.session[key] = value; 540 | } 541 | } 542 | req.res.clearCookie('apos-connect'); 543 | }, 544 | async redirectToNewLocale(req) { 545 | if (!req.session.passportLocale) { 546 | return; 547 | } 548 | const i18n = self.apos.i18n; 549 | const { 550 | oldLocale, 551 | newLocale, 552 | oldAposDocId 553 | } = req.session.passportLocale; 554 | delete req.session.passportLocale; 555 | const crossDomainSessionToken = self.apos.util.generateId(); 556 | await self.apos.cache.set('@apostrophecms/i18n:cross-domain-sessions', crossDomainSessionToken, req.session, 60 * 60); 557 | let doc = await self.apos.doc.find(req, { 558 | aposDocId: oldAposDocId 559 | }).locale(`${oldLocale}:draft`).relationships(false).areas(false).toObject(); 560 | if (doc && doc.aposDocId) { 561 | doc = await self.apos.doc.find(req, { 562 | aposDocId: doc.aposDocId 563 | }).locale(`${newLocale}:draft`).toObject(); 564 | } 565 | let route; 566 | if (doc) { 567 | const action = self.apos.page.isPage(doc) 568 | ? self.apos.page.action 569 | : self.apos.doc.getManager(doc).action; 570 | route = `${action}/${doc._id}/locale/${newLocale}`; 571 | } else { 572 | // Fall back to home page, with appropriate prefix 573 | route = '/'; 574 | if (i18n.locales[newLocale] && i18n.locales[newLocale].prefix) { 575 | route = i18n.locales[newLocale].prefix + '/'; 576 | } 577 | } 578 | 579 | let url = self.apos.url.build(route, { 580 | aposLocale: req.oldLocale, 581 | aposCrossDomainSessionToken: crossDomainSessionToken 582 | }); 583 | 584 | if (i18n.locales[newLocale] && i18n.locales[newLocale].hostname) { 585 | const oldLocale = req.locale; 586 | // Force use of correct hostname for new locale 587 | req.locale = newLocale; 588 | url = self.apos.page.getBaseUrl(req) + url; 589 | req.locale = oldLocale; 590 | } 591 | req.session.passportRedirect = url; 592 | } 593 | }, 594 | 'apostrophe:modulesRegistered': { 595 | addRoutes() { 596 | Object.values(self.specs).forEach(spec => { 597 | self.addLoginRoute(spec); 598 | self.addCallbackRoute(spec); 599 | self.addFailureRoute(spec); 600 | self.addConnectRoute(spec); 601 | }); 602 | } 603 | } 604 | }; 605 | }, 606 | tasks(self) { 607 | return { 608 | listUrls: { 609 | usage: 'Run this task to list the login URLs for each registered strategy.\n' + 610 | 'This is helpful when writing markup to invite users to log in.', 611 | task: () => { 612 | const specs = Object.values(self.specs); 613 | // eslint-disable-next-line no-console 614 | console.log('These are the login URLs you may wish to link users to:\n'); 615 | specs.forEach(spec => { 616 | // eslint-disable-next-line no-console 617 | console.log(`${spec.label}: ${self.getLoginUrl(spec, true)}`); 618 | }); 619 | // eslint-disable-next-line no-console 620 | console.log('\nThese are the callback URLs you may need to configure on sites:\n'); 621 | specs.forEach(spec => { 622 | // eslint-disable-next-line no-console 623 | console.log(`${spec.label}: ${self.getCallbackUrl(spec, true)}`); 624 | }); 625 | } 626 | } 627 | }; 628 | }, 629 | components(self) { 630 | return { 631 | loginLinks(req, data) { 632 | return { 633 | links: Object.values(self.specs).map(spec => { 634 | let href = self.getLoginUrl(spec, true); 635 | if (Object.keys(self.apos.i18n.locales).length > 1) { 636 | const context = req.data.piece || req.data.page; 637 | href = self.apos.url.build(href, { 638 | oldLocale: req.locale, 639 | newLocale: req.locale.replace(':draft', ':published'), 640 | oldAposDocId: (context && context.aposDocId) 641 | }); 642 | } 643 | return { 644 | name: spec.name, 645 | label: spec.label, 646 | href 647 | }; 648 | }) 649 | }; 650 | } 651 | }; 652 | } 653 | }; 654 | 655 | function getBundleModuleNames() { 656 | const source = path.join(__dirname, './modules/@apostrophecms'); 657 | return fs 658 | .readdirSync(source, { withFileTypes: true }) 659 | .filter(dirent => dirent.isDirectory()) 660 | .map(dirent => `@apostrophecms/${dirent.name}`); 661 | } 662 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 17 | 18 | **Enable enterprise-grade single sign-on (SSO) and social login** for your ApostropheCMS applications. Seamlessly integrate with Google Workspace, GitHub, GitLab, Auth0, and dozens of other identity providers to streamline user authentication and improve security. 19 | 20 | ## Why Passport Bridge? 21 | 22 | - **🔐 Enterprise Security**: Leverage your existing identity infrastructure (Google Workspace, Azure AD, Okta) 23 | - **⚡ Zero Password Fatigue**: Users log in once with credentials they already know and trust 24 | - **🛡️ Reduced Security Risk**: Eliminate password storage and management on your site 25 | - **👥 Team-Ready**: Perfect for organizations where users already have company accounts 26 | - **🚀 Developer Friendly**: Works with 500+ [Passport.js strategies](https://www.passportjs.org/) with minimal configuration 27 | - **💰 Cost Effective**: Reduce support overhead from password resets and account management 28 | 29 | ## Installation 30 | 31 | To install the module, use the command line to run this command in an Apostrophe project's root directory: 32 | 33 | ```bash 34 | npm install @apostrophecms/passport-bridge 35 | # Example: Google OAuth (many other providers available) 36 | npm install --save passport-google-oauth20 37 | ``` 38 | 39 | Most modules that have "passport" in the name and let you log in via a third-party website will work. 40 | 41 | ## Usage 42 | 43 | Enable the `@apostrophecms/passport-bridge` module in the `app.js` file: 44 | 45 | ```javascript 46 | import apostrophe from 'apostrophe'; 47 | 48 | apostrophe ({ 49 | root: import.meta, 50 | // Configuring baseUrl is mandatory for this module. For local dev 51 | // testing you can set it to http://localhost:3000 while in production 52 | // it must be real and correct 53 | baseUrl: 'http://myproductionurl.com', 54 | shortName: 'my-project', 55 | modules: { 56 | '@apostrophecms/passport-bridge': {} 57 | } 58 | }); 59 | ``` 60 | 61 | Then configure the module in `modules/@apostrophecms/passport-bridge/index.js` in your project folder: 62 | 63 | ```javascript 64 | export default { 65 | // In modules/@apostrophecms/passport-bridge/index.js 66 | options: { 67 | strategies: [ 68 | { 69 | // You must npm install --save this module in your project first 70 | module: 'passport-google-oauth20', 71 | options: { 72 | // Options for passport-google-oauth20 73 | clientID: process.env.GOOGLE_CLIENT_ID, 74 | clientSecret: process.env.GOOGLE_CLIENT_SECRET 75 | }, 76 | // Ignore users whose email address does not match this domain 77 | // according to the identity provider 78 | emailDomain: 'YOUR-DOMAIN-HERE.com', 79 | // Use the user's email address as their identity 80 | match: 'email', 81 | // Strategy-specific options that must be passed to the authenticate middleware. 82 | // See the documentation of the strategy module you are using 83 | authenticate: { 84 | // 'email' for the obvious, 'profile' for the displayName (for the create option) 85 | scope: [ 'email', 'profile' ] 86 | } 87 | } 88 | ] 89 | } 90 | }; 91 | ``` 92 | 93 | > ⚠️ Since we're not using the `create` option, users must actually exist in 94 | > Apostrophe with the same username or email address, depending on the 95 | > `match` option. If you want to automatically create users in Apostrophe, 96 | > see [creating users on demand](#creating-users-on-demand) below. 97 | 98 | ### Working with passport strategies that expect different arguments to the `verify` callback 99 | 100 | All passport strategies expect us to provide a `verify` callback. By default, `@apostrophecms/passport-bridge` passes its `findOrCreateUser` function as the `verify` callback, which works for many strategies including `passport-oauth2`, `passport-github2` and `passport-gitlab2`. For other strategies, you can pass an explicit `verify` option which will remap the strategy `verify` method to our `@apostrophecms/passport-bridge` `findOrCreateUser` method. 101 | 102 | This method is responsible for retrieving the user in the ApostropheCMS database, or creating it. It is the `@apostrophecms/passport-bridge` equivalent of the strategy `verify` method. 103 | 104 | For example, for `passport-oauth2`, the module's documentation shows the following: 105 | 106 | ```javascript 107 | // https://www.passportjs.org/packages/passport-oauth2/ 108 | passport.use(new OAuth2Strategy({ 109 | authorizationURL: 'https://www.example.com/oauth2/authorize', 110 | tokenURL: 'https://www.example.com/oauth2/token', 111 | clientID: EXAMPLE_CLIENT_ID, 112 | clientSecret: EXAMPLE_CLIENT_SECRET, 113 | callbackURL: "http://localhost:3000/auth/example/callback" 114 | }, 115 | function(accessToken, refreshToken, profile, cb) { 116 | User.findOrCreate({ exampleId: profile.id }, function (err, user) { 117 | return cb(err, user); 118 | }); 119 | } 120 | )); 121 | ``` 122 | 123 | The second parameter of the strategy is the stratey `verify` method (`accessToken`, `refreshToken`, `profile`, `done`). 124 | 125 | The default value for the `verify` option is equivalent to the following 126 | 127 | ```javascript 128 | module.exports = { 129 | // In modules/@apostrophecms/passport-bridge/index.js 130 | options: { 131 | strategies: [ 132 | { 133 | module: 'passport-oauth2|passport-github2|passport-gitlab2', 134 | options: { 135 | // ... 136 | // Default value for the verify option 137 | // verify: findOrCreateUser => 138 | // async (req, accessToken, refreshToken, profile, done) => 139 | // findOrCreateUser(req, accessToken, refreshToken, profile, done) 140 | } 141 | }, 142 | // ... 143 | } 144 | ] 145 | } 146 | }; 147 | ``` 148 | 149 | If you're using `passport-auth0` or any other auth strategy for which the strategy `verify` method is different, please use the new `@apostrophecms/passport-bridge` `verify` option. 150 | 151 | For instance, the `passport-auth0` module has a `verify` method that expects different arguments: 152 | 153 | ```javascript 154 | // https://www.passportjs.org/packages/passport-auth0/ 155 | const Auth0Strategy = require('passport-auth0'); 156 | const strategy = new Auth0Strategy({ 157 | // ... 158 | state: false 159 | }, 160 | function(accessToken, refreshToken, extraParams, profile, done) { 161 | // ... 162 | } 163 | ); 164 | ``` 165 | 166 | To solve for this, you can do the following: 167 | 168 | ```javascript 169 | module.exports = { 170 | // In modules/@apostrophecms/passport-bridge/index.js 171 | options: { 172 | strategies: [ 173 | { 174 | module: 'passport-auth0', 175 | options: { 176 | // ... 177 | verify: findOrCreateUser => 178 | async (req, accessToken, refreshToken, extraParams, profile, done) => 179 | findOrCreateUser(req, accessToken, refreshToken, profile, done) 180 | } 181 | }, 182 | // ... 183 | } 184 | ] 185 | } 186 | }; 187 | ``` 188 | 189 | If provided, the `verify` option must be a function. 190 | 191 | That function accepts the normal verify callback from the passport bridge module, and returns an alternative function which accepts `req`, plus the arguments that are typical for `passport-auth0` or the strategy of your choice, and then invokes the normal verify callback with the arguments it expects, returning the result. 192 | 193 | ### Working with `oidc-client` and other passport strategies that don't follow typical patterns 194 | 195 | Some passport strategies are more challenging than others. In particular, OIDC is best implemented with the [oidc-client](https://www.npmjs.com/package/oidc-client) module, but there are several challenges: 196 | 197 | * Because of the way it is exported, the strategy cannot simply be `require`d for you by passport-bridge. 198 | * The strategy does not accept a simple object of parameters for initialization. 199 | * You will likely want to use the built-in discovery feature. 200 | * The `verify` function has a different pattern. 201 | * OIDC's standard "claims" are named differently from the profile properties commonly seen with oauth2-based strategies. 202 | 203 | To solve for all of these, use the `factory` option. Here is a complete solution for `oidc-client`. 204 | 205 | First, make sure you have the prerequisites: 206 | 207 | * `npm install openid-client` 208 | * `npm install @apostrophecms/passport-bridge` 209 | * Add `@apostrophecms/passport-bridge: {}` to your `modules` section in `app.js` 210 | * Set the `OIDC_ISSUER`, `OIDC_CLIENT_ID`, and `OIDC_CLIENT_SECRET` environment variables 211 | 212 | *Note that `OIDC_ISSUER` should be the URL of an identity provider that supports OIDC discovery, which most 213 | OIDC providers do.* 214 | 215 | Now configure the passport bridge module: 216 | 217 | ```javascript 218 | // in modules/@apostrophecms/passport-bridge/index.js of your project 219 | // (do NOT modify the module in node_modules) 220 | 221 | import * as client from 'openid-client'; 222 | import { 223 | Strategy 224 | } from 'openid-client/passport' 225 | 226 | export default { 227 | options: { 228 | // Optional 229 | create: { 230 | role: 'guest' 231 | }, 232 | strategies: [ 233 | { 234 | async factory(params, fn) { 235 | const issuer = new URL(process.env.OIDC_ISSUER); 236 | const config = await client.discovery( 237 | issuer, 238 | process.env.OIDC_CLIENT_ID, 239 | process.env.OIDC_CLIENT_SECRET 240 | ); 241 | const { 242 | // injected into params by passport-bridge 243 | callbackURL 244 | } = params; 245 | const strategy = new Strategy({ 246 | config, 247 | scope: 'openid email', 248 | callbackURL 249 | }, (tokens, callback) => { 250 | const claims = tokens.claims(); 251 | 252 | const profile = { 253 | id: claims.sid, 254 | displayName: claims.name, 255 | firstName: claims.given_name, 256 | lastName: claims.family_name, 257 | email: claims.email, 258 | username: claims.preferred_username 259 | }; 260 | return fn(null, tokens.access_token, tokens.refresh_token, profile, callback); 261 | }); 262 | 263 | // The strategy sets it to the hostname, which varies. Override so we can predict the URLs 264 | strategy.name = 'oidc'; 265 | return strategy; 266 | }, 267 | // Also required when using a factory function 268 | name: 'oidc', 269 | // These would show up in "params" above, we chose to use environment 270 | // variables instead 271 | options: {}, 272 | // Use the user's email address as their identity. You 273 | // could also specify 'id', or 'username' if the latter is unique 274 | match: 'email' 275 | } 276 | ] 277 | } 278 | } 279 | ``` 280 | 281 | ### Adding login links for a traditional ApostropheCMS project 282 | 283 | The easiest way to enable login is to use the `loginLinks` async component in your template: 284 | 285 | ```markup 286 | {% component "@apostrophecms/passport-bridge:loginLinks" %} 287 | ``` 288 | 289 | This component will output links that attempt to bring the user back to the same page after login, and to keep them in the same locale even if your site has separate hostnames configured for separate locales. 290 | 291 | You can override this template's markup by copying `views/loginLinks.html` from this npm module to your project-level `modules/@apostrophecms/passport-bridge/views` folder. 292 | 293 | You can also determine the login URLs by invoking the `@apostrophecms/passport-bridge:list-urls` task, however this method does not give you a way to preserve the current URL or redirect back to the current locale's hostname. 294 | 295 | ### Using login links with headless ApostropheCMS and an Astro Frontend 296 | 297 | When using `@apostrophecms/passport-bridge` with an Astro frontend (via [`@apostrophecms/apostrophe-astro`](https://github.com/apostrophecms/apostrophe-astro)), the built-in `loginLinks` component won't work since Astro handles template rendering instead of Nunjucks. 298 | 299 | **1. Configure Astro Proxy Routes** 300 | 301 | First, configure your Astro frontend to proxy authentication routes to ApostropheCMS. In your `astro.config.mjs` within the `apostrophe` configuration: 302 | 303 | ```javascript 304 | integrations: [ 305 | apostrophe({ 306 | aposHost: 'http://localhost:3000', 307 | proxyRoutes: [ 308 | '/auth/[...slug]' 309 | ] 310 | // remainder of configuration 311 | }) 312 | ] 313 | ``` 314 | 315 | This ensures all authentication-related routes (such as `/auth/google/login`, `/auth/github/login`, `/auth/gitlab/callback`, etc.) are properly forwarded to your ApostropheCMS backend. 316 | 317 | **2. Configure Passport Bridge with Redirects** 318 | 319 | Update your passport bridge configuration to handle successful and failed authentication redirects to your Astro frontend. The key addition is the `successRedirect` and `failureRedirect` properties in the authenticate section: 320 | 321 | ```javascript 322 | // backend/modules/@apostrophecms/passport-bridge/index.js 323 | export default { 324 | options: { 325 | strategies: [ 326 | { 327 | // Example with Google OAuth - adapt for your chosen strategy 328 | module: 'passport-google-oauth20', 329 | options: { 330 | clientID: process.env.GOOGLE_CLIENT_ID, 331 | clientSecret: process.env.GOOGLE_CLIENT_SECRET, 332 | callbackURL: `${process.env.APOS_HOST || 'http://localhost:3000'}/auth/google/callback` 333 | }, 334 | match: 'email', 335 | authenticate: { 336 | scope: ['email', 'profile'], 337 | // These redirects are crucial for Astro integration: 338 | successRedirect: process.env.APOS_BASE_URL || 'http://localhost:4321/', 339 | failureRedirect: `${process.env.APOS_BASE_URL || 'http://localhost:4321'}/login?error=oauth_failed` 340 | } 341 | } 342 | ] 343 | } 344 | }; 345 | ``` 346 | 347 | **3. Add a login link to your frontend template** 348 | 349 | In the backend portion of the project use the CLI task to list the configured authentication URLs. 350 | 351 | ```bash 352 | node app @apostrophecms/passport-bridge:listUrls 353 | ``` 354 | This will provide the URL (or multiple URLs if you have multiple strategies defined) that you can add to your desired frontend component. 355 | 356 | ## Environment Variables 357 | 358 | Make sure to set the following environment variables: 359 | 360 | - `APOS_HOST`: Your ApostropheCMS backend URL (e.g., `http://localhost:3000`) 361 | - `APOS_BASE_URL`: Your Astro frontend URL (e.g., `http://localhost:4321`) 362 | - Strategy-specific variables like `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` 363 | 364 | ## Handling Authentication State 365 | 366 | After successful authentication, users will be redirected to your Astro frontend. You can check authentication state by making requests to your ApostropheCMS backend's session endpoints or by implementing additional API routes to expose user information. 367 | 368 | ## Error Handling 369 | 370 | The `failureRedirect` configuration will send users to your specified error page (e.g., `/login?error=oauth_failed`) where you can display appropriate error messages based on URL parameters. 371 | 372 | ### Configuring your identity provider 373 | 374 | #### What is my oauth callback URL? 375 | 376 | Many strategies require an oauth callback URL. To discover those, run this command line task to print the URLs for login, and for the oauth callback URLs: 377 | 378 | ``` 379 | node app @apostrophecms/passport-bridge:listUrls 380 | ``` 381 | 382 | You'll see something like: 383 | 384 | ``` 385 | These are the login URLs you may wish to link users to: 386 | 387 | /auth/gitlab/login 388 | 389 | These are the callback URLs you may need to configure on sites: 390 | 391 | http://localhost:3000/auth/gitlab/callback 392 | ``` 393 | 394 | ⚠️ You can use a URL like `http://localhost:3000` for testing but in production you must use your production URL. Most identity providers will reject a URL beginning with `http:` or an IP address, except for `http://localhost:3000` which is often accepted for testing purposes only. 395 | 396 | #### Where do I get my `clientID`, `clientSecret`, etc.? 397 | 398 | You get these from the identity provider, usually by adding an "app" to your profile or developer console. In the case of Google you will need to [create an application in the Google API console and authorize it to perform oauth logins](https://developers.google.com/). See the documentation of the passport strategy module you're using. 399 | 400 | ### Creating users on demand 401 | 402 | If you wish you can enable automatic creation of new accounts for any user who is valid according to your login strategy, for instance any user in your Google workspace. 403 | 404 | ```javascript 405 | module.exports = { 406 | // In modules/@apostrophecms/passport-bridge/index.js 407 | options: { 408 | ... 409 | create: { 410 | // If you wish to treat all valid google users in your domain as 411 | // admins of the site. See also `guest`, `contributor`, `editor` 412 | // 413 | role: 'admin' 414 | } 415 | } 416 | }; 417 | ``` 418 | 419 | ### Beefing up the "create" option: copying extra properties 420 | 421 | The "create" option shown above will create a user with minimal information: first name, last name, full name, username, and email address (where available). 422 | 423 | If you wish to import other fields from the profile object provided by the passport strategy, add an `import` function to your configuration for that strategy. The `import` function receives `(profile, user)` and may copy properties from `profile` to `user` as it sees fit. It may not be an async function. 424 | 425 | ### Multiple strategies 426 | 427 | You may enable more than one strategy at the same time. Just configure them consecutively in the `strategies` array. This means you can have login via Twitter, Google, etc. on the same site. 428 | 429 | > ⚠️ Take care when choosing what identity providers to trust. When using single sign-on, your site's security is only as good as that of the identity provider you are trusting. If multiple strategies are enabled with `email` as the matching method, and a malicious user succeeds in creating an account with that email address that matches any of the strategies, then that is sufficient for them to log in. Most major public providers, like Facebook, Twitter or Google, do require the user to prove they control an email address before associating it with an account. 430 | 431 | ## Accessing the user's `accessToken` and `refreshToken` to make API calls 432 | 433 | When we authenticate the user via an identity provider like `github` that has APIs 434 | of its own, it is often desirable to call additional APIs of that provider. 435 | 436 | Setting the `retainAccessToken` option to `true` retains the `accessToken` and `refreshToken` in Apostrophe's "safe," which is a special storage place for sensitive data associated with a user. 437 | 438 | You can then access that data like this: 439 | 440 | ```javascript 441 | const tokens = await self.apos.user.getTokens(req.user, 'github'); 442 | if (tokens) { 443 | // Use tokens.accessToken and, sometimes, tokens.refreshToken 444 | } else { 445 | // Tell the user to connect with github again 446 | } 447 | ``` 448 | 449 | A passport strategy name is always required. Unfortunately, this is not the same thing as 450 | the npm module name. If you do not know the strategy name, check 451 | the `strategy.js` file in the source code of the Passport strategy module you are 452 | using, such as `passport-github`. 453 | 454 | There is no guarantee that a particular strategy supports tokens, or requires both 455 | `accessToken` and `refreshToken`. 456 | 457 | Access tokens can expire. If the access token expires and the strategy you are using 458 | supports OAuth refresh tokens (not OIDC), you can ask Apostrophe to refresh it: 459 | 460 | ```javascript 461 | // Passing in the existing refresh token is optional, but avoids an extra database call 462 | const { accessToken, refreshToken } = await self.apos.user.refreshTokens(req.user, 'github', refreshToken); 463 | ``` 464 | 465 | If the refresh fails, an exception is thrown. In addition, if it fails with a 466 | "401: Unauthorized" error, the tokens are removed, so that the next call 467 | to `getTokens` will return null. 468 | 469 | If you need to refresh the tokens yourself by other means, you can pass in the result: 470 | 471 | ```javascript 472 | // We obtained these new tokens by means of our own 473 | await self.apos.user.updateTokens(req.user, 'github', { accessToken, refreshToken }); 474 | ``` 475 | 476 | Passing in the existing access token and refresh token is optional, and avoids 477 | waiting for an extra database call. 478 | 479 | > Determining whether an access token has expired will depend on the platform-specific APIs you 480 | are calling, but most will return a `401` status code in this situation. 481 | 482 | To simplify this flow, use `withAccessToken`. Here is an example 483 | where the github Octokit API is used. The API request in the nested function is first made with 484 | the existing access token. If an exception with a `status` property equal to `401` 485 | is thrown, the token is refreshed and updated, and the nested function is invoked again 486 | with the new token. If the refreshed access token also fails with a `401`, the error is 487 | allowed to throw. All other errors are allowed to pass through. 488 | 489 | ```javascript 490 | const { Octokit } = require("@octokit/rest"); 491 | 492 | const repos = await self.apos.user.withAccessToken(req.user, 'github', async (accessToken, unauthorized) => { 493 | const octokit = Octokit({ auth: accessToken }); 494 | return req.octokit.rest.repos.listForAuthenticatedUser({ 495 | affiliation: 'owner', 496 | // 100 is the max allowed per page 497 | per_page: 100 498 | }); 499 | }); 500 | // Do something cool with `repos` 501 | ``` 502 | 503 | Not all APIs that expect access tokens are created equal. If the API you are calling throws 504 | an error in this situation that doesn't have `status: 401`, you can throw a suitable 505 | object yourself (pseudocode): 506 | 507 | ```javascript 508 | try { 509 | await someStrangeAPI(accessToken); 510 | } catch (e) { 511 | // Just an example, your mileage will vary 512 | if (e.toString().includes('unauthorized')) { 513 | throw { 514 | status: 401 515 | }; 516 | } else { 517 | // Some other error, let it fail 518 | throw e; 519 | } 520 | } 521 | ``` 522 | 523 | ## Issues with multiple services 524 | 525 | ### Conflicting usernames 526 | 527 | If a user is already logged in, for instance via Apostrophe's standard login screen, 528 | and then passes through the Passport flow to log in via a second identity provider, 529 | Passport will log the user out of the first account by default, and in most cases 530 | will wind up creating a second account, or mistakenly reuse an account associated 531 | with a different service. 532 | 533 | This problem can be mitigated by setting `match` to `email` for each strategy, as long 534 | as the user has the same email address in each case and the service in question 535 | offers email addresses as an option. 536 | 537 | ### "Connecting" accounts without creating a second account 538 | 539 | An individual may want to associate an ordinary Apostrophe account with a secondary service, 540 | such as a github account, that has a different email address. Unfortunately, in this case, 541 | simply following a link to the login URL for a second service this will log the user out of 542 | the first account and log them into an entirely separate account based on the email address 543 | from github when using `match: 'email'` as described above. If using `match: 'id'`, the 544 | behavior is more consistent, but still undesirable: a separate account is always created. 545 | 546 | This can be addressed via the following flow: 547 | 548 | 1. The user logs in normally to their Apostrophe account. 549 | 550 | 2. Await `requestConnection` to generate a confirmation link and email it 551 | to the current user's email address. When this method resolves, the email has been 552 | handed off for delivery, and it is appropriate to tell the user to expect it soon. 553 | 554 | > Apostrophe must be 555 | > [correctly configured for reliable email delivery](https://v3.docs.apostrophecms.org/guide/sending-email.html#sending-email-from-your-apostrophe-project). 556 | > If you do not take appropriate steps to ensure this, the email probably will not get through. 557 | 558 | ```javascript 559 | await self.apos.user.requestConnection(req, 'STRATEGY NAME HERE', { 560 | redirectTo: '/site/relative/url/here', 561 | }); 562 | ``` 563 | 564 | > The strategy name depends on the passport strategy in question. `passport-github` uses 565 | > the strategy name `github`. You can find it in the source of the strategy module 566 | > you are using and it is usually your first guess as well. 567 | 568 | 3. The user receives the email and follows the link provided. 569 | 570 | 4. The user is redirected to authorize access to their `github` account (in this example). 571 | 572 | 5. The user is redirected to the home page, or to the URL you optionally specify via 573 | `redirectTo`. They are still logged into the original account. Their strategy-specific id 574 | is captured in their `user` piece as `githubId` (in the case of the github strategy; 575 | substitute the appropriate strategy name), and their tokens are available as described 576 | earlier if `retainSessionToken: true` is set. 577 | 578 | > Note that for security reasons, the link in the email is only valid for twenty-four hours. 579 | 580 | ### Overriding the email template 581 | 582 | To override the email message that is sent, copy `views/connectEmail.html` from 583 | the `@apostrophecms/passport-bridge` npm module to your project-level 584 | `modules/@apostrophecms/passport-bridge/views` folder, and edit that template you see fit. 585 | 586 | ### Session properties 587 | 588 | Note that when following this flow the user's original req.session properties are 589 | preserved. Normally this is not possible, because Passport 0.6 or better always 590 | regenerates the session on a new login. 591 | 592 | ### Logging in via the secondary strategy 593 | 594 | In this example, a user who "connects" their account to github will be able to 595 | "log in via github" in the future, if they so choose. Since we trust that github 596 | maintains good security, and they proved control of the original account before 597 | connecting with github, this is usually acceptable. 598 | 599 | However, if you wish to block this for a particular strategy you can specify 600 | the `login: false` option when configuring that strategy. If you take this 601 | path, users will be able to "connect" an account using that strategy to their 602 | original account, but will not be able to log in via that strategy alone. In this 603 | situation the secondary strategy is present for API token access only. 604 | 605 | ### Disconnecting a strategy from an account 606 | 607 | You can disconnect a strategy at any time: 608 | 609 | ```javascript 610 | await self.apos.user.removeConnection(req, 'STRATEGY NAME HERE'); 611 | ``` 612 | 613 | This will clear the related strategy-specific id, e.g. it will purge `githubId` 614 | if the strategy name is `github`. 615 | 616 | ## Frequently asked questions 617 | 618 | ### Where do I `require` or `import` the passport strategy? 619 | 620 | You *can* do that, if you use the `factory` option, as shown above. This is useful with modules that have an unusual initialization process, like `openid-client`. 621 | 622 | But in many cases, you don't have to. Apostrophe can do it for you. Passing the module name in the appropriate entry in your `strategies` array is enough. The `options` sub-property and sometimes also the `authenticate` sub-property are useful if your chosen strategy has options that must be passed to its `authenticate` middleware, as with Google and most others (you'll see this in the documentation of the strategy you are using). 623 | 624 | ### Can I change how users are mapped between the identity provider and my site? 625 | 626 | If you don't like the default behavior, you can change it. The mapping is up to you. Usernames and emails are *almost* permanent, but people do change them and that can be problematic, especially if they are reused by someone else. 627 | 628 | On the other hand, IDs are a pain to work with if you are creating users in advance and not using the `create` feature of the module. 629 | 630 | You can set the `match` option for any strategy to one of the following choices: 631 | 632 | #### `id` 633 | 634 | Matches on the id of their profile as returned by the strategy module. This is most unique, however if you don't set `create`, then you'll need to find out the ids of users in advance and populate them in your database. You could do that by adding a string field to the `fields` configuration of the `@apostrophecms/user` module in your project. 635 | 636 | To accommodate multiple strategies, If the strategy name is `google`, then the id needs to be in the `googleId` field of the user. If the strategy name is `gitlab`, the id needs to be in `gitlabId`, and so on. If you are using the `create` feature, these properties are automatically populated for you. 637 | 638 | **The strategy name and the npm module name are not quite the same thing.** Look at the output of `node app @apostrophecms/passport-bridge:list-urls`. The word that follows `/auth` is the strategy name. 639 | 640 | #### `email` 641 | 642 | This will match on any email the authentication provider indicates they own, whether it is an array in the `.emails` property of their profile containing objects with `.value` properties (as with Google), an array of strings in `.emails`, or just an `email` string property. *To minimize confusion you can also set `match` to `emails` which has the same effect. Either way it will check all three cases.* 643 | 644 | #### `username` 645 | 646 | The default. Users are matched based on having the same username. 647 | 648 | #### A function of your choice 649 | 650 | If you provide a function rather than a string, it will receive the user's profile from the passport strategy, and must return a MongoDB criteria object matching the appropriate user. Do not worry about checking the `disabled` or `type` properties, Apostrophe will handle that. 651 | 652 | ### How can I reject users in a customized way? 653 | 654 | You can set your own policy for rejecting users by passing an `accept` function for any strategy. This function takes the `profile` object provided by the passport strategy and must return `true` otherwise the user is not permitted to log in. 655 | 656 | ### How can I lock down my site by email address domain name? 657 | 658 | You may wish to accept only users from one email domain, which is very handy if your company's email is hosted by Google (aka "G Suite", aka "Google Workspaces"). For that, also set the `emailDomain` option to the domain name you wish to allow. All others are rejected. This is very important if you are using the `create` option. 659 | 660 | ### How can I reject direct logins via Apostrophe's login form? 661 | 662 | "This is great, but I want to disable the regular `/login` page." You can: 663 | 664 | ```javascript 665 | // in app.js 666 | modules: { 667 | '@apostrophecms/passport-bridge': { 668 | // As above; this is not where we disable local login... 669 | }, 670 | '@apostrophecms/login': { 671 | // We disable it here, by configuring the built-in @apostrophecms/login module 672 | localLogin: false 673 | } 674 | } 675 | ``` 676 | 677 | The built-in login page is powered by Passport's `local` strategy, which is added to Apostrophe by the standard `@apostrophecms/login` module. That's why we disable it there and not in `@apostrophecms/passport-bridge`'s options. 678 | 679 | ### How can I override the error page? 680 | 681 | If login fails, for instance because you are matching on `email` but the `username` duplicates another account, or because a user is valid in Google but `emailDomain` does not match, the `error.html` template of the `apostrophe-passport` module is rendered. By default, it works, but it's pretty ugly! You'll want to customize it to your project's needs. 682 | 683 | Like other templates in Apostrophe, you can override this template by copying it to `modules/@apostrophecms/passport-bridge/views/error.html` *in your project* (**never modify the npm module itself**). You can then extend your own layout template and so on, just as you have most likely already done for the 404 Not Found page. 684 | 685 | ### How can I redirect the standard `/login` page to one of my strategies? 686 | 687 | Once you have disabled the regular login page, it's possible for you to decide what happens at that URL. Use the [@apostrophecms/redirect](https://npmjs.org/package/@apostrophecms/redirect) module to set it up through a nice UI, or add an Express route and a redirect in your own code. 688 | 689 | ### What if it doesn't work? 690 | 691 | Feel free to open an issue but be sure to provide full specifics and a test project. Note that some strategies may not follow the standard practices this module is built upon. Those written by Jared Hanson, the author of Passport, or following his best practices should work well. You might want to test directly with the sample code provided with that strategy module first, to rule out problems with the module or with your configuration of it. 692 | 693 | ### How can I debug the system? 694 | 695 | By default this module will log quite a bit of information in a development environment, using the `debug` ApostropheCMS log level. When `NODE_ENV` is production this logging is suppressed by default. See the [`@apostrophecms/log` module documentation](https://docs.apostrophecms.org/guide/logging.html) for information how to change this. 696 | 697 | --- 698 | 699 |Made with ❤️ by the ApostropheCMS team. Found this useful? Give us a star on GitHub! ⭐ 701 |
702 |