現在、自動リフレッシュトークンローテーションのビルトインソリューションはありません。このガイドは、アプリケーションでこれを実現するのに役立ちます。最終的には、ビルトインプロバイダーに対するゼロコンフィグサポートを追加することを目標としています。お知らせください。
リフレッシュトークンのローテーションとは?
リフレッシュトークンのローテーションとは、ユーザーの代わりにaccess_token
を更新する手法であり、ユーザーの操作(例:再認証)は必要ありません。access_token
は通常、限定された時間のために発行されます。有効期限が切れると、それを検証するサービスはその値を無視し、access_token
を無効にします。access_token
を取得するためにユーザーに再度サインインを要求する代わりに、多くのプロバイダーは最初のサインイン時により長い有効期限を持つrefresh_token
も発行します。Auth.jsライブラリは、このrefresh_token
を使用して、ユーザーに再度サインインを要求することなく新しいaccess_token
を取得するように設定できます。
実装
以下のガイドには、セキュリティ上の理由からrefresh_token
は通常一度しか使用できないという固有の制限があります。つまり、正常にリフレッシュされた後、refresh_token
は無効になり、再度使用できなくなります。したがって、複数のリクエストが同時にトークンをリフレッシュしようとすると、競合状態が発生する可能性があります。Auth.jsチームはこの問題を認識しており、将来的な解決策を提供したいと考えています。これには、複数のリクエストが同時にトークンをリフレッシュしようとするのを防ぐための「ロック」メカニズムが含まれる可能性がありますが、アプリケーションのボトルネックを作成する可能性という欠点があります。もう1つの可能な解決策は、認証済みリクエスト中にトークンが期限切れになるのを防ぐためのバックグラウンドトークンリフレッシュです。
まず、使用するプロバイダーがrefresh_token
をサポートしていることを確認してください。OAuth 2.0 Authorization Framework仕様の詳細をご覧ください。セッション戦略に応じて、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://#/.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://#/.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
}
}