feat(auth): refresh token Directus explicite dans le callback JWT NextAuth

Sans ce correctif, l'access token Directus (~15 min) expirait silencieusement,
rendant toutes les requêtes API 401 sans déconnecter l'utilisateur.

- Ajout de refreshDirectusToken() : POST /auth/refresh avec rotation du refresh_token
- accessTokenExpires stocké dès la connexion (expires Directus - marge 60s)
- jwt callback : token valide → pass-through, token expiré → refresh, échec → error flag
- session callback : propagation de session.error = 'RefreshAccessTokenError'
  (permet au client de forcer un signOut si le refresh_token est lui-même expiré)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-14 06:37:11 +04:00
parent 7b831d5bc4
commit d8a771161c
2 changed files with 53 additions and 13 deletions
+50 -10
View File
@@ -1,3 +1,4 @@
/* eslint-disable new-cap */
import CredentialsProvider from 'next-auth/providers/credentials' import CredentialsProvider from 'next-auth/providers/credentials'
import {readMe, withToken} from '@directus/sdk' import {readMe, withToken} from '@directus/sdk'
import {directusClient} from '@/lib/directus.js' import {directusClient} from '@/lib/directus.js'
@@ -5,9 +6,39 @@ import {directusClient} from '@/lib/directus.js'
const apiUrl = process.env.DIRECTUS_API_URL const apiUrl = process.env.DIRECTUS_API_URL
const nextauthSecret = process.env.NEXTAUTH_SECRET const nextauthSecret = process.env.NEXTAUTH_SECRET
// On rafraîchit 60s avant l'échéance réelle pour éviter les races
const REFRESH_MARGIN_MS = 60 * 1000
/**
* Appelle l'endpoint Directus /auth/refresh et retourne les nouveaux tokens.
* Lève une erreur si le refresh échoue (refresh_token expiré ou révoqué).
*
* @param {string} refreshToken
* @returns {Promise<{accessToken: string, refreshToken: string, accessTokenExpires: number}>}
*/
async function refreshDirectusToken(refreshToken) {
const res = await fetch(`${apiUrl}/auth/refresh`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({refresh_token: refreshToken, mode: 'json'}),
})
if (!res.ok) {
throw new Error(`Directus refresh failed with status ${res.status}`)
}
const {data} = await res.json()
return {
accessToken: data.access_token,
refreshToken: data.refresh_token,
accessTokenExpires: Date.now() + (data.expires ?? 900_000) - REFRESH_MARGIN_MS,
}
}
export const options = { export const options = {
providers: [ providers: [
CredentialsProvider({ // eslint-disable-line new-cap CredentialsProvider({
name: 'Credentials', name: 'Credentials',
credentials: { credentials: {
email: {}, email: {},
@@ -36,18 +67,13 @@ export const options = {
signIn: '/login' signIn: '/login'
}, },
callbacks: { callbacks: {
async jwt({ async jwt({token, user, account}) {
token, // Connexion initiale : enrichissement du token avec les données Directus
user,
account
}) {
if (account && user) { if (account && user) {
const userData = await directusClient.request( const userData = await directusClient.request(
withToken( withToken(
user.data.access_token, user.data.access_token,
readMe({ readMe({fields: ['*']})
fields: ['*']
})
) )
) )
@@ -55,11 +81,24 @@ export const options = {
...token, ...token,
accessToken: user.data.access_token, accessToken: user.data.access_token,
refreshToken: user.data.refresh_token, refreshToken: user.data.refresh_token,
user: userData accessTokenExpires: Date.now() + (user.data.expires ?? 900_000) - REFRESH_MARGIN_MS,
user: userData,
} }
} }
// Token encore valide : on le retourne sans modification
if (Date.now() < token.accessTokenExpires) {
return token return token
}
// Token expiré : tentative de rafraîchissement silencieux
try {
const refreshed = await refreshDirectusToken(token.refreshToken)
return {...token, ...refreshed}
} catch (refreshError) {
console.error('Refresh token Directus échoué :', refreshError.message)
return {...token, error: 'RefreshAccessTokenError'}
}
}, },
async session({session, token}) { async session({session, token}) {
session.user.userId = token.user.id session.user.userId = token.user.id
@@ -69,6 +108,7 @@ export const options = {
session.user.token = token.user.token session.user.token = token.user.token
session.user.email = token.user.email session.user.email = token.user.email
session.user.password = token.user.password session.user.password = token.user.password
session.error = token.error
return session return session
} }
} }
+2 -2
View File
@@ -12,8 +12,8 @@
- [x] **Headers CSP**`next.config.mjs` (renommé depuis .js) avec CSP + 4 headers sécurité - [x] **Headers CSP**`next.config.mjs` (renommé depuis .js) avec CSP + 4 headers sécurité
- [x] **Tests unitaires** — Vitest sur `lib/format.js`, `lib/version-utils.js`, `lib/rate-limit.js` - [x] **Tests unitaires** — Vitest sur `lib/format.js`, `lib/version-utils.js`, `lib/rate-limit.js`
- [ ] **Tests extensions Directus** — mocks VersionsService - [x] **Tests extensions Directus** — mocks VersionsService
- [ ] **Refresh token explicite** — callback `jwt` dans NextAuth options - [x] **Refresh token explicite** — callback `jwt` dans NextAuth options
- [ ] **Pipeline CI** — GitHub Actions (lint + test + build) - [ ] **Pipeline CI** — GitHub Actions (lint + test + build)
- [ ] **Sentry** — tracking erreurs frontend + API routes - [ ] **Sentry** — tracking erreurs frontend + API routes