Skip to content

Commit

Permalink
docs(guides): tweak refresh token guide
Browse files Browse the repository at this point in the history
  • Loading branch information
balazsorban44 committed Aug 8, 2024
1 parent 2070a33 commit 862a505
Showing 1 changed file with 111 additions and 93 deletions.
204 changes: 111 additions & 93 deletions docs/pages/guides/refresh-token-rotation.mdx
Original file line number Diff line number Diff line change
@@ -1,26 +1,48 @@
import { Code } from "@/components/Code"
import { Callout } from "nextra/components"

Refresh token rotation is the practice of updating an `access_token` on behalf of the user, without requiring interaction (eg.: re-sign in). `access_token`s are usually issued for a limited time. After they expire, the service verifying them will ignore the value. Instead of asking the user to sign in again to obtain a new `access_token`, certain providers support exchanging a `refresh_token` for a new `access_token`, renewing the expiry time. Refreshing your `access_token` with other providers will look very similar, you will just need to adjust the endpoint and potentially the contents of the body being sent to them in the request.

<Callout>
Our goal is to add zero-config support for built-in providers eventually. Let
us know if you would like to help.
As of today, there is no built-in solution for automatic Refresh Token
rotation. This guide will help you to achieve this in your application. Our
goal is to add zero-config support for built-in providers eventually. [Let us
know](/contributors#core-team) if you would like to help.
</Callout>

## Implementation
## What is refresh token rotation?

First, make sure that the provider you want to use supports `refresh_token`'s. Check out [The OAuth 2.0 Authorization Framework](https://www.rfc-editor.org/rfc/rfc6749#section-6) spec for more details. Depending on the session strategy, the `refresh_token` can be persisted either in a database, in a cookie, or in an encrypted JWT.
Refresh token rotation is the practice of updating an `access_token` on behalf of the user, without requiring interaction (ie.: re-authenticating).
`access_token`s are usually issued for a limited time. After they expire, the service verifying them will ignore the value, rendering the `access_token` useless.
Instead of asking the user to sign in again to obtain a new `access_token`, many providers also issue a `refresh_token` during initial signin, that has a longer expiry date.
Auth.js libraries can be configured to use this `refresh_token` to obtain a new `access_token` without requiring the user to sign in again.

<Callout>
While using a JWT to store the `refresh_token` is very common, it is less
secure than saving it in a database as it is easier for a potential attacker
to retrieve from a JWT compared to your applications database. You need to
evaluate based on your requirements which strategy you choose.
## Implementation

<Callout type="info">
There is an inherent limitation of the following guides that comes from the
fact, that - for security reasons - `refresh_token`s are usually only usable
once. Meaning that after a successful refresh, the `refresh_token` will be
invalidated and cannot be used again. Therefore, in some cases, a
race-condition might occur if multiple requests will try to refresh the token
at the same time. The Auth.js team is aware of this and would like to provide
a solution in the future. This might include some "lock" mechanism to prevent
multiple requests from trying to refresh the token at the same time, but that
comes with the drawback of potentically creating a bottleneck in the
application. Another possible solution is background token refresh, to prevent
the token from expiring during an authenticated request.
</Callout>

First, make sure that the provider you want to use supports `refresh_token`'s. Check out [The OAuth 2.0 Authorization Framework](https://www.rfc-editor.org/rfc/rfc6749#section-6) spec for more details.
Depending on the [session strategy](/concepts/session-strategies), the `refresh_token` can be persisted either in an encrypted JWT inside a cookie or in a database.

### JWT strategy

<Callout>
While using a cookie to store the `refresh_token` is simpler, it is less
secure. To mitigate risks with the `strategy: "jwt"`, Auth.js libraries store
the `refresh_token` in an _encrypted_ JWT, in an `HttpOnly` cookie. Still, you
need to evaluate based on your requirements which strategy you choose.
</Callout>

Using the [jwt](/reference/core/types#jwt) and [session](/reference/core/types#session) callbacks, we can persist OAuth tokens and refresh them when they expire.

Below is a sample implementation of refreshing the `access_token` with Google. Please note that the OAuth 2.0 request to get the `refresh_token` will vary between different providers, but the rest of logic should remain similar.
Expand All @@ -35,96 +57,82 @@ import Google from "next-auth/providers/google"
export const { handlers, auth } = NextAuth({
providers: [
Google({
clientId: process.env.AUTH_GOOGLE_ID,
clientSecret: process.env.AUTH_GOOGLE_SECRET,
// Google requires "offline" access_type to provide a `refresh_token`
authorization: { params: { access_type: "offline", prompt: "consent" } },
}),
],
callbacks: {
async jwt({ token, account, profile }) {
if (account) {
// First login, save the `access_token`, `refresh_token`, and other
// details into the JWT

const userProfile: User = {
id: token.sub,
name: profile?.name,
email: profile?.email,
image: token?.picture,
}

if (profile && 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,
user: userProfile,
}
} else if (Date.now() < token.expires_at * 1000) {
// Subsequent logins, if the `access_token` is still valid, return the JWT
// Subsequent logins, but the `access_token` is still valid
return token
} else {
// Subsequent logins, if the `access_token` has expired, try to refresh it
if (!token.refresh_token) throw new Error("Missing refresh token")
// 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://accounts.google.com/.well-known/openid-configuration
const response = await fetch("https://oauth2.googleapis.com/token", {
headers: { "Content-Type": "application/x-www-form-urlencoded" },
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!,
}),
method: "POST",
})

const responseTokens = await response.json()
const tokensOrError = await response.json()

if (!response.ok) throw responseTokens
if (!response.ok) throw tokensOrError

return {
// Keep the previous token properties
...token,
access_token: responseTokens.access_token,
expires_at: Math.floor(Date.now() / 1000 + (responseTokens.expires_in as number)),
// Fall back to old refresh token, but note that
// many providers may only allow using a refresh token once.
refresh_token: responseTokens.refresh_token ?? token.refresh_token,
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)
// The error property can be used client-side to handle the refresh token error
return { ...token, error: "RefreshAccessTokenError" as const }
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 }) {
if (token.user) {
session.user = token.user as User
}

return session
},
},
},
})

declare module "next-auth" {
interface Session {
error?: "RefreshAccessTokenError"
error?: "RefreshTokenError"
}
}

declare module "next-auth/jwt" {
interface JWT {
access_token: string
expires_at: number
refresh_token: string
error?: "RefreshAccessTokenError"
refresh_token?: string
error?: "RefreshTokenError"
}
}
```
Expand All @@ -134,7 +142,7 @@ declare module "next-auth/jwt" {

### Database strategy

Using the database session strategy is very similar, but instead of preserving the `access_token` and `refresh_token` in the JWT, we will save it in the database by updating the `account` value.
Using the database session strategy is similar, but instead we will save the `access_token`, `expires_at` and `refresh_token` on the `account` for the given provider.

<Code>
<Code.Next>
Expand All @@ -151,8 +159,6 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
adapter: PrismaAdapter(prisma),
providers: [
Google({
clientId: process.env.AUTH_GOOGLE_ID,
clientSecret: process.env.AUTH_GOOGLE_SECRET,
authorization: { params: { access_type: "offline", prompt: "consent" } },
}),
],
Expand All @@ -167,28 +173,31 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
// https://accounts.google.com/.well-known/openid-configuration
// We need the `token_endpoint`.
const response = await fetch("https://oauth2.googleapis.com/token", {
headers: { "Content-Type": "application/x-www-form-urlencoded" },
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,
}),
method: "POST",
})

const responseTokens = await response.json()
const tokensOrError = await response.json()

if (!response.ok) throw responseTokens
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: responseTokens.access_token,
expires_at: Math.floor(
Date.now() / 1000 + responseTokens.expires_in
),
access_token: newTokens.access_token,
expires_at: Math.floor(Date.now() / 1000 + newTokens.expires_in),
refresh_token:
responseTokens.refresh_token ?? googleAccount.refresh_token,
newTokens.refresh_token ?? googleAccount.refresh_token,
},
where: {
provider_providerAccountId: {
Expand All @@ -198,9 +207,9 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
},
})
} catch (error) {
console.error("Error refreshing access token", error)
// The error property can be used client-side to handle the refresh token error
session.error = "RefreshAccessTokenError"
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
Expand All @@ -210,44 +219,53 @@ export const { handlers, signIn, signOut, auth } = NextAuth({

declare module "next-auth" {
interface Session {
error?: "RefreshAccessTokenError"
}
}

declare module "next-auth/jwt" {
interface JWT {
access_token: string
expires_at: number
refresh_token: string
error?: "RefreshAccessTokenError"
error?: "RefreshTokenError"
}
}
```

</Code.Next>
</Code>

### Client Side
### Error handling

The `RefreshAccessTokenError` error that is caught in the `session` callback is passed to the client. This means that you can direct the user to the sign-in flow if we cannot refresh their token. Don't forget, calling `useSession` client-side, for example, requires your component is wrapped with the `<SessionProvider />`.
If the token refresh was unsuccesful, we can force a re-authentication.

We can handle this functionality as a side effect:
<Code>

<Code.Next>

```tsx filename="app/dashboard/page.tsx"
"use client";
import { useEffect } from "react"
import { auth, signIn } from "@/auth"

import { useEffect } from "react";
import { signIn, useSession } from "next-auth/react";
export default async function Page() {
const session = await useSession()
if (session?.error === "RefreshTokenError") {
await signIn("google") // Force sign in to obtain a new set of access and refresh tokens
}
}
```

const HomePage() {
const { data: session } = useSession();
</Code.Next>

useEffect(() => {
if (session?.error === "RefreshAccessTokenError") {
signIn(); // Force sign in to hopefully resolve error
}
}, [session]);
<Code.NextClient>

```tsx filename="app/dashboard/page.tsx"
"use client"

return <div>Home Page</div>;
import { useEffect } from "react"
import { signIn, useSession } from "next-auth/react"

export default function Page() {
const { data: session } = useSession()
useEffect(() => {
if (session?.error !== "RefreshTokenError") return
signIn("google") // Force sign in to obtain a new set of access and refresh tokens
}, [session?.error])
}
```

</Code.NextClient>

</Code>

0 comments on commit 862a505

Please sign in to comment.