Druxt Authentication with Drupal Simple OAuth2 and nuxt/auth.
- DruxtJS: https://druxtjs.org
- Community Discord server: https://discord.druxtjs.org
$ npm install druxt-auth
Add module to nuxt.config.js
module.exports = {
modules: [
'druxt',
[
'druxt-auth',
{
clientId: '[DRUPAL_CONSUMER_CLIENT_ID]',
// Only for the password grant, and only a confidential Consumer.
clientSecret: '[DRUPAL_CONSUMER_SECRET]',
},
],
],
druxt: {
baseUrl: 'https://demo-api.druxtjs.org',
},
}Note: Use modules, not buildModules: this module registers the
authentication endpoints and proxy at runtime, and buildModules are not
loaded by nuxt start, so authentication would silently stop working in
production while the dev server looks fine.
Note: replace [DRUPAL_CONSUMER_CLIENT_ID] and [DRUPAL_CONSUMER_SECRET] with the details from the consumer created in the following step. With Simple OAuth 6 this is the consumer's Client ID field, not its UUID.
-
Download, install and setup the Simple OAuth module.
-
Simple OAuth 6.x only: create an OAuth2 scope (
/admin/config/people/simple_oauth/oauth2_scope/dynamic). Simple OAuth 6 has no scopes configured, and it rejects every authorization request - with or without ascopeparameter - until one exists that the request can resolve:- Grant types: enable Authorization code, and Refresh token too if you want sessions to renew. The refresh grant revalidates the scope it carries, so a scope without it fails renewal
- Granularity: e.g. Role with the
authenticatedrole
-
Create a Consumer depending on your desired authorization strategy:
-
Authorization Code grant:
- Client ID: a unique ID of your choosing - this is the
clientIdthe frontend sends (Simple OAuth 6 looks consumers up by this field, not by UUID) - New Secret: leave this empty
- Is Confidential: unchecked
- Use PKCE?: checked
- Grant types: enable Authorization code (and Refresh token for session renewal)
- Authorization code scopes: the scope from the previous step. This
is the default when the frontend does not send a scope of its own, which
is what DruxtAuth does unless the
scopeoption is set - Redirect URI:
[FRONTEND_URL]/callback(e.g.,http://localhost:3000/callback)
- Client ID: a unique ID of your choosing - this is the
-
Password grant:
- New Secret: provide a secure secret
- Is Confidential: checked
- Redirect URI:
[FRONTEND_URL]/callback(e.g.,http://localhost:3000/callback)
-
-
Authorization Code grant only: give the role your users hold the Grant OAuth2 codes permission (
grant simple_oauth codes). Without it the consent screen returns to itself withThe 'grant simple_oauth codes' permission is required.and no login completes. User 1 bypasses permission checks, so test with a normal account.
The DruxtAuth module installs and configures the nuxt/auth module for your Druxt site.
It adds two auth strategies that can be used via the $auth plugin:
-
drupal-authorization_codethis.$nuxt.$auth.loginWith('drupal-authorization_code')
With credentials, it signs in through Drupal's JSON login first, so the authorize step finds a session and returns without showing a Drupal page.
logout()ends that Drupal session too, andresetPassword()asks Drupal to email a reset link:await this.$auth.loginWith('drupal-authorization_code', { credentials: { name: '', pass: '' }, }) await this.$auth.strategy.resetPassword('editor@example.com')
resetPassword()treats a value with an@as an address. A Drupal username may contain@, so name the field for those accounts:await this.$auth.strategy.resetPassword('editor@example.com', 'name')
Note: The session cookie must reach the authorize request, which needs the browser to see the login and the authorize step on one site:
Setup Credentials Nuxt server proxying Drupal, on any servers Works, and druxt: { proxy: { api: true } }sets it up. The module proxies/user/login,/user/logout,/user/password,/oauth/authorize,/oauth/tokenand/session/token, and points theauthorizationendpoint at the site.Subdomains of one domain, no proxy Point the endpoints at Drupal's absolute URLs, and allow credentials for the frontend's origin in Drupal's CORS. Different domains, no proxy Not supported: the session cookie would be a third-party cookie. Call loginWithwithout credentials, which redirects to Drupal's login page as before./user/loginis proxied for POST alone, which is the verb Drupal's JSON login answers on. A GET reaches the frontend, so a login page at that path still renders.The Consumer must approve automatically, or the authorize step shows Drupal's consent page.
A Drupal session already open in the browser refuses these credentials, rather than signing the visitor in as whoever left it there. A session this module opened is ended and the sign in retried, so an abandoned authorisation does not lock anyone out. Any other session is refused, and the error has
sessionInUseset so a form can say why.Drupal core cannot end a session it did not issue a logout token for. Add a route to the backend that can, point
sessionLogoutat it, and that session is ended instead of refused. Writing the route is the site's job:auth: { strategies: { 'drupal-authorization_code': { endpoints: { // The route, and the verb it answers on. sessionLogout: '/your/route', sessionLogoutMethod: 'post', // Where the CSRF token comes from. Core's own route, on every // Drupal. Set this to null for a route that takes no token. csrfToken: '/session/token', }, }, }, }
The module reads a token from
csrfTokenand sends it asX-CSRF-Token. A route protected the way core protects its writes requires that header, and answers 403 without it. -
drupal-passwordSimple OAuth 6 moved the password grant out of core. Install simple_oauth_password_grant on the backend and enable Password on the Consumer's grant types.
this.$nuxt.$auth.loginWith('drupal-password', { data: { username: '', password: '' }, })
The username and password reach Drupal through this module's own server route, so the site must run in SSR mode. Set
clientSecretfor a confidential Consumer. A public one needs none, and the request leaves it out rather than sending an empty value.A Consumer cannot be public and confidential at once, so a site running both this and the browser flow needs two: set
passwordClientIdto the second.The grant issues a token and no Drupal session, so Drupal's own pages stay anonymous under it, however signed in the frontend looks. A site that proxies those pages, for the admin UI or an editor's forms, sets
passwordSession:druxt: { auth: { clientId: '...', passwordClientId: '...', passwordSession: true }, proxy: { api: true }, }
The sign-in then opens a Drupal session through the proxied
/user/loginwith the same credentials, before the grant, andlogout()ends both. It needs/user/loginreaching Drupal on the site's origin, which the proxy provides. Off by default: a frontend that talks to the API alone has no use for the session or the request. -
See the nuxt/auth documentation for more details: https://auth.nuxtjs.org/api/auth
Sessions renew on their own, with no application code. Both strategies store
a refresh token when the backend issues one, and nuxt/auth puts an
interceptor on the shared $axios instance: a request made with an expired
access token triggers a refresh_token grant first, then goes out with the
new token. That covers DruxtClient requests too, because Druxt shares the
same instance.
| Situation | What happens |
|---|---|
| Access token expires while the page is open | The next request refreshes it, silently |
| Page reloaded with an expired access token | The tokens are in cookies, so the server render refreshes and the page hydrates logged in |
| Refresh token expired or rejected | The session resets and the request is aborted with ExpiredAuthSessionError |
| No refresh token stored | The request goes out with the expired token, and the backend refuses it |
Both of the following apply:
- The backend must issue refresh tokens: enable the Refresh token grant on both the consumer and the scope.
- nuxt/auth assumes a refresh token lives 30 days, because Simple OAuth's refresh tokens are opaque and carry no expiry to read. A consumer with a shorter lifetime (14 days is the usual default) rejects the refresh in between, which ends the session mid-request. Match the two, or expect a login prompt at the consumer's lifetime rather than at 30 days.
- Setting
druxt.axiosgives the DruxtClient its own axios instance, which the interceptor never sees. Attach the token yourself in that case.
$auth.logout() ends the frontend session and nothing else. Simple OAuth
does not serve a revocation endpoint, so the tokens it issued stay valid until they
expire, and the refresh token can still mint new access tokens for its whole
lifetime. Ending them at logout needs a revocation route on the Drupal side
(issue 2945273
carries a patch), called through the Nuxt proxy so it shares the frontend
origin.
It also leaves its own storage keys behind, in both cookies and localStorage,
holding the string "false". The keys are named for the strategy, so
auth._token.drupal-authorization_code, not auth._token.druxt.
example/nuxt/pages/user/logout.vue is a logout page that clears them and
forces a full page load, which is also what empties the DruxtStore of content
fetched while logged in.
The module adds a /user/login page with a sign in form:
<DruxtAuthLogin />Put it wherever you like instead:
<DruxtAuthLogin redirect="/account" />The form matches what the strategy can do. On the drupal-authorization_code
strategy it asks for a username and password and signs in without sending the
visitor to Drupal. On a strategy that cannot take credentials it renders a
button that starts the redirect instead.
The credentials form needs two conditions. Drupal must be
same origin with the frontend, because the session cookie has to reach
/oauth/authorize, so use the API proxy above. The Consumer must also have
Automatically authorize this client set, or Drupal shows its own consent
page and the visitor leaves the site.
The proxy and this page share the /user/login path and do not collide. The
module proxies that path for POST alone, which is what Drupal's JSON login
answers on, so a GET reaches this page.
A component that replaces the form receives the username and password, since it renders the fields. Treat an override the way you would treat any code handling a password.
Add your own pages/user/login.vue and the module leaves the route alone,
so upgrading changes nothing for a site that already has a login page.
To theme the form rather than replace the page, add a component named after
the strategy, or DruxtAuthLoginDefault for all of them:
<!-- components/DruxtAuthLoginDrupalAuthorizationCode.vue -->
<template>
<form @submit.prevent="submit">
<p v-if="error">{{ error }}</p>
<input v-model="credentials.name" />
<input v-model="credentials.pass" type="password" />
<button :disabled="busy">Sign in</button>
</form>
</template>
<script>
export default {
props: ['busy', 'capabilities', 'credentials', 'error', 'reset', 'submit', 'resetPassword'],
}
</script>Drupal revokes a user's access tokens whenever that user is saved, so an editor who saves their own profile would otherwise come back to a site that thinks it is signed in and is refused every request.
The module handles this on its own, with no configuration: the refresh token is untouched, so the next request refreshes and replays. Only a rapid burst of saves outpaces the recovery, and the request after the burst recovers.
Upstream this is drupal.org issue 2946882.
| Option | Type | Required | Default | Description |
|---|---|---|---|---|
clientId |
string |
Yes | undefined |
The Drupal Consumer's Client ID field, not its UUID |
passwordClientId |
string |
No | clientId |
The Consumer the password grant authenticates as, when it differs from the browser flow's. |
clientSecret |
string |
No | undefined |
The Drupal Consumer API secret. The password grant sends it, and a public Consumer needs none. |
login |
string/boolean |
No | /user/login |
Where the sign in page goes. false leaves it out. A page the site already has always wins. |
scope |
array |
No | undefined |
The OAuth scopes to request. When unset, no scope parameter is sent and Simple OAuth 6 falls back to the consumer's own Authorization code scopes - so either set this option or configure scopes on the consumer. |