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:
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return token
|
// Token encore valide : on le retourne sans modification
|
||||||
|
if (Date.now() < token.accessTokenExpires) {
|
||||||
|
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
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user