截至今日,尚無針對自動更新令牌輪換的內建解決方案。本指南將協助您在應用程式中實現此目的。我們的目標是最終為內建提供者新增零配置支援。如果您想幫忙,請告訴我們。
什麼是更新令牌輪換?
更新令牌輪換是指在不需要互動(例如:重新驗證)的情況下,代表使用者更新 access_token
的做法。access_token
通常會在有限的時間內發放。在過期後,驗證它們的服務會忽略該值,導致 access_token
無法使用。許多提供者除了要求使用者再次登入以取得新的 access_token
之外,還會在初始登入期間發放具有較長過期日期的 refresh_token
。Auth.js 程式庫可以設定為使用此 refresh_token
來取得新的 access_token
,而無需使用者再次登入。
實作
以下指南有一個固有的限制,源於以下事實:基於安全原因,refresh_token
通常只能使用一次。這表示在成功更新後,refresh_token
將會失效,無法再次使用。因此,在某些情況下,如果多個請求嘗試同時更新令牌,則可能會發生競爭條件。Auth.js 團隊意識到這一點,並希望未來能提供解決方案。這可能包括一些「鎖定」機制,以防止多個請求同時嘗試更新令牌,但這樣做可能會在應用程式中造成瓶頸。另一個可能的解決方案是背景令牌更新,以防止令牌在經過驗證的請求期間過期。
首先,請確定您要使用的提供者支援 refresh_token
。請查看 OAuth 2.0 授權框架規範以取得更多詳細資訊。根據 會話策略,refresh_token
可以儲存在 Cookie 中加密的 JWT 內,或儲存在資料庫中。
JWT 策略
雖然使用 Cookie 儲存 refresh_token
比較簡單,但安全性較低。為了降低 strategy: "jwt"
的風險,Auth.js 程式庫會將 refresh_token
儲存在 HttpOnly
Cookie 中加密的 JWT 中。不過,您仍然需要根據您的需求評估選擇哪種策略。
使用 jwt 和 session 回呼,我們可以儲存 OAuth 令牌,並在它們過期時更新它們。
以下是使用 Google 更新 access_token
的範例實作。請注意,取得 refresh_token
的 OAuth 2.0 請求在不同的提供者之間會有所不同,但其餘邏輯應保持相似。
import NextAuth, { type User } from "next-auth"
import Google from "next-auth/providers/google"
export const { handlers, auth } = NextAuth({
providers: [
Google({
// Google requires "offline" access_type to provide a `refresh_token`
authorization: { params: { access_type: "offline", prompt: "consent" } },
}),
],
callbacks: {
async jwt({ token, account }) {
if (account) {
// First-time login, save the `access_token`, its expiry and the `refresh_token`
return {
...token,
access_token: account.access_token,
expires_at: account.expires_at,
refresh_token: account.refresh_token,
}
} else if (Date.now() < token.expires_at * 1000) {
// Subsequent logins, but the `access_token` is still valid
return token
} else {
// Subsequent logins, but the `access_token` has expired, try to refresh it
if (!token.refresh_token) throw new TypeError("Missing refresh_token")
try {
// The `token_endpoint` can be found in the provider's documentation. Or if they support OIDC,
// at their `/.well-known/openid-configuration` endpoint.
// i.e. https://127.0.0.1/.well-known/openid-configuration
const response = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
body: new URLSearchParams({
client_id: process.env.AUTH_GOOGLE_ID!,
client_secret: process.env.AUTH_GOOGLE_SECRET!,
grant_type: "refresh_token",
refresh_token: token.refresh_token!,
}),
})
const tokensOrError = await response.json()
if (!response.ok) throw tokensOrError
const newTokens = tokensOrError as {
access_token: string
expires_in: number
refresh_token?: string
}
token.access_token = newTokens.access_token
token.expires_at = Math.floor(
Date.now() / 1000 + newTokens.expires_in
)
// Some providers only issue refresh tokens once, so preserve if we did not get a new one
if (newTokens.refresh_token)
token.refresh_token = newTokens.refresh_token
return token
} catch (error) {
console.error("Error refreshing access_token", error)
// If we fail to refresh the token, return an error so we can handle it on the page
token.error = "RefreshTokenError"
return token
}
}
},
async session({ session, token }) {
session.error = token.error
return session
},
},
})
declare module "next-auth" {
interface Session {
error?: "RefreshTokenError"
}
}
declare module "next-auth/jwt" {
interface JWT {
access_token: string
expires_at: number
refresh_token?: string
error?: "RefreshTokenError"
}
}
資料庫策略
使用資料庫會話策略類似,但我們會將 access_token
、expires_at
和 refresh_token
儲存在指定提供者的 account
上。
import NextAuth from "next-auth"
import Google from "next-auth/providers/google"
import { PrismaAdapter } from "@auth/prisma-adapter"
import { PrismaClient } from "@prisma/client"
const prisma = new PrismaClient()
export const { handlers, signIn, signOut, auth } = NextAuth({
adapter: PrismaAdapter(prisma),
providers: [
Google({
authorization: { params: { access_type: "offline", prompt: "consent" } },
}),
],
callbacks: {
async session({ session, user }) {
const [googleAccount] = await prisma.account.findMany({
where: { userId: user.id, provider: "google" },
})
if (googleAccount.expires_at * 1000 < Date.now()) {
// If the access token has expired, try to refresh it
try {
// https://127.0.0.1/.well-known/openid-configuration
// We need the `token_endpoint`.
const response = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
body: new URLSearchParams({
client_id: process.env.AUTH_GOOGLE_ID!,
client_secret: process.env.AUTH_GOOGLE_SECRET!,
grant_type: "refresh_token",
refresh_token: googleAccount.refresh_token,
}),
})
const tokensOrError = await response.json()
if (!response.ok) throw tokensOrError
const newTokens = tokensOrError as {
access_token: string
expires_in: number
refresh_token?: string
}
await prisma.account.update({
data: {
access_token: newTokens.access_token,
expires_at: Math.floor(Date.now() / 1000 + newTokens.expires_in),
refresh_token:
newTokens.refresh_token ?? googleAccount.refresh_token,
},
where: {
provider_providerAccountId: {
provider: "google",
providerAccountId: googleAccount.providerAccountId,
},
},
})
} catch (error) {
console.error("Error refreshing access_token", error)
// If we fail to refresh the token, return an error so we can handle it on the page
session.error = "RefreshTokenError"
}
}
return session
},
},
})
declare module "next-auth" {
interface Session {
error?: "RefreshTokenError"
}
}
錯誤處理
如果令牌更新不成功,我們可以強制重新驗證。
import { useEffect } from "react"
import { auth, signIn } from "@/auth"
export default async function Page() {
const session = await auth()
if (session?.error === "RefreshTokenError") {
await signIn("google") // Force sign in to obtain a new set of access and refresh tokens
}
}