apostrophe-passport
works together with passport-google-oauth20
, passport-gitlab2
and similar passport strategy modules to let users log in to Apostrophe CMS sites via Google, Gitlab and other identity providers. This feature is often called federation or single sign-on.
To install the module, use the command line to run this command in an Apostrophe project's root directory:
npm install @apostrophecms/passport-bridge
# Just an example — you can use many strategy modules
npm install --save passport-google-oauth20
Most modules that have "passport" in the name and let you log in via a third-party website will work.
Enable the @apostrophecms/passport-bridge
module in the app.js
file:
require('apostrophe')({
// Configuring baseUrl is mandatory for this module. For local dev
// testing you can set it to http://localhost:3000 while in production
// it must be real and correct
baseUrl: 'http://myproductionurl.com',
shortName: 'my-project',
modules: {
'@apostrophecms/passport-bridge': {}
}
});
Then configure the module in modules/@apostrophecms/passport-bridge/index.js
in your project folder:
module.exports = {
// In modules/@apostrophecms/passport-bridge/index.js
options: {
strategies: [
{
// You must npm install --save this module in your project first
module: 'passport-google-oauth20',
options: {
// Options for passport-google-oauth20
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET
},
// Ignore users whose email address does not match this domain
// according to the identity provider
emailDomain: 'YOUR-DOMAIN-HERE.com',
// Use the user's email address as their identity
match: 'email',
// Strategy-specific options that must be passed to the authenticate middleware.
// See the documentation of the strategy module you are using
authenticate: {
// 'email' for the obvious, 'profile' for the displayName (for the create option)
scope: [ 'email', 'profile' ]
}
}
]
}
};
⚠️ Since we're not using thecreate
option, users must actually exist in Apostrophe with the same username or email address, depending on thematch
option. If you want to automatically create users in Apostrophe, see creating users on demand below.
The easiest way to enable login is to use the loginLinks
async component in your template:
{% component "@apostrophecms/passport-bridge:loginLinks" %}
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.
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.
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.
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:
node app @apostrophecms/passport-bridge:listUrls
You'll see something like:
These are the login URLs you may wish to link users to:
/auth/gitlab/login
These are the callback URLs you may need to configure on sites:
http://localhost:3000/auth/gitlab/callback
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.
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. See the documentation of the passport strategy module you're using.
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.
module.exports = {
// In modules/@apostrophecms/passport-bridge/index.js
options: {
...
create: {
// If you wish to treat all valid google users in your domain as
// admins of the site. See also `guest`, `contributor`, `editor`
//
role: 'admin'
}
}
};
The "create" option shown above will create a user with minimal information: first name, last name, full name, username, and email address (where available).
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.
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.
⚠️ 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
When we authenticate the user via an identity provider like github
that has APIs
of its own, it is often desirable to call additional APIs of that provider.
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.
You can then access that data like this:
const tokens = await self.apos.user.getTokens(req.user, 'github');
if (tokens) {
// Use tokens.accessToken and, sometimes, tokens.refreshToken
} else {
// Tell the user to connect with github again
}
A passport strategy name is always required. Unfortunately, this is not the same thing as
the npm module name. If you do not know the strategy name, check
the strategy.js
file in the source code of the Passport strategy module you are
using, such as passport-github
.
There is no guarantee that a particular strategy supports tokens, or requires both
accessToken
and refreshToken
.
Access tokens can expire. If the access token expires and the strategy you are using supports OAuth refresh tokens, you can ask Apostrophe to refresh it:
// Passing in the existing refresh token is optional, but avoids an extra database call
const { accessToken, refreshToken } = await self.apos.user.refreshTokens(req.user, 'github', refreshToken);
If the refresh fails, an exception is thrown. In addition, if it fails with a
"401: Unauthorized" error, the tokens are removed, so that the next call
to getTokens
will return null.
If you need to refresh the tokens yourself by other means, you can pass in the result:
// We obtained these new tokens by means of our own
await self.apos.user.updateTokens(req.user, 'github', { accessToken, refreshToken });
Passing in the existing access token and refresh token is optional, and avoids waiting for an extra database call.
Determining whether an access token has expired will depend on the platform-specific APIs you are calling, but most will return a
401
status code in this situation.
To simplify this flow, use withAccessToken
. Here is an example
where the github Octokit API is used. The API request in the nested function is first made with
the existing access token. If an exception with a status
property equal to 401
is thrown, the token is refreshed and updated, and the nested function is invoked again
with the new token. If the refreshed access token also fails with a 401
, the error is
allowed to throw. All other errors are allowed to pass through.
const { Octokit } = require("@octokit/rest");
const repos = await self.apos.user.withAccessToken(req.user, 'github', async (accessToken, unauthorized) => {
const octokit = Octokit({ auth: accessToken });
return req.octokit.rest.repos.listForAuthenticatedUser({
affiliation: 'owner',
// 100 is the max allowed per page
per_page: 100
});
});
// Do something cool with `repos`
Not all APIs that expect access tokens are created equal. If the API you are calling throws
an error in this situation that doesn't have status: 401
, you can throw a suitable
object yourself (pseudocode):
try {
await someStrangeAPI(accessToken);
} catch (e) {
// Just an example, your mileage will vary
if (e.toString().includes('unauthorized')) {
throw {
status: 401
};
} else {
// Some other error, let it fail
throw e;
}
}
If a user is already logged in, for instance via Apostrophe's standard login screen, and then passes through the Passport flow to log in via a second identity provider, Passport will log the user out of the first account by default, and in most cases will wind up creating a second account, or mistakenly reuse an account associated with a different service.
This problem can be mitigated by setting match
to email
for each strategy, as long
as the user has the same email address in each case and the service in question
offers email addresses as an option.
An individual may want to associate an ordinary Apostrophe account with a secondary service,
such as a github account, that has a different email address. Unfortunately, in this case,
simply following a link to the login URL for a second service this will log the user out of
the first account and log them into an entirely separate account based on the email address
from github when using match: 'email'
as described above. If using match: 'id'
, the
behavior is more consistent, but still undesirable: a separate account is always created.
This can be addressed via the following flow:
-
The user logs in normally to their Apostrophe account.
-
Await
requestConnection
to generate a confirmation link and email it to the current user's email address. When this method resolves, the email has been handed off for delivery, and it is appropriate to tell the user to expect it soon.
Apostrophe must be correctly configured for reliable email delivery. If you do not take appropriate steps to ensure this, the email probably will not get through.
await self.apos.user.requestConnection(req, 'STRATEGY NAME HERE', {
redirectTo: '/site/relative/url/here',
});
The strategy name depends on the passport strategy in question.
passport-github
uses the strategy namegithub
. You can find it in the source of the strategy module you are using and it is usually your first guess as well.
-
The user receives the email and follows the link provided.
-
The user is redirected to authorize access to their
github
account (in this example). -
The user is redirectd to the home page, or to the URL you optionally specify via
redirectTo
. They are still logged into the original account. Their strategy-specific id is captured in theiruser
piece asgithubId
(in the case of the github strategy; substitute the appropriate strategy name), and their tokens are available as described earlier ifretainSessionToken: true
is set.
Note that for security reasons, the link in the email is only valid for twenty-four hours.
To override the email message that is sent, copy views/connectEmail.html
from
the @apostrophecms/passport-bridge
npm module to your project-level
modules/@apostrophecms/passport-bridge/views
folder, and edit that template you see fit.
Note that when following this flow the user's original req.session properties are preserved. Normally this is not possible, because Passport 0.6 or better always regenerates the session on a new login.
In this example, a user who "connects" their account to github will be able to "log in via github" in the future, if they so choose. Since we trust that github maintains good security, and they proved control of the original account before connecting with github, this is usually acceptable.
However, if you wish to block this for a particular strategy you can specify
the login: false
option when configuring that strategy. If you take this
path, users will be able to "connect" an account using that strategy to their
original account, but will not be able to log in via that strategy alone. In this
situation the secondary strategy is present for API token access only.
You can disconnect a strategy at any time:
await self.apos.user.removeConnection(req, 'STRATEGY NAME HERE');
This will clear the related strategy-specific id, e.g. it will purge githubId
if the strategy name is github
.
You don't. Apostrophe does it for you. You pass its configuration as part of the strategies
option, via the options
sub-property and sometimes also the authenticate
sub-property if your chosen strategy has options that must be passed to its authenticate
middleware, as with Google (you'll see this in its documentation).
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.
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.
You can set the match
option for any strategy to one of the following choices:
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.
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.
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.
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.
The default. Users are matched based on having the same username.
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.
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.
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.
"This is great, but I want to disable the regular /login
page." You can:
// in app.js
modules: {
'@apostrophecms/passport-bridge': {
// As above; this is not where we disable local login...
},
'@apostrophecms/login': {
// We disable it here, by configuring the built-in @apostrophecms/login module
localLogin: false
}
}
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.
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.
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.
Once you have disabled the regular login page, it's possible for you to decide what happens at that URL. Use the @apostrophecms/redirect module to set it up through a nice UI, or add an Express route and a redirect in your own code.
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.