Merged in feature/refresh-token (pull request #401)

feat: SW-101 Proactively refresh tokens

Approved-by: Michael Zetterberg
This commit is contained in:
Niclas Edenvin
2024-08-16 13:56:09 +00:00
parent 9f69e383e8
commit 819ac454b0
8 changed files with 167 additions and 67 deletions

13
Auth.md Normal file
View File

@@ -0,0 +1,13 @@
# Auth
The web is using OAuth 2.0 to handle auth. We host our own instance of [Curity](https://curity.io), which is our identity and access management solution.
## Session management in Next
We use [Auth.js](https://authjs.dev) to handle everything regarding auth in the web. We use the JWT session strategy, which means that everything regarding the session is stored in a JWT, which is stored in the browser in an encrypted cookie.
## Keeping the access token alive
When the user performs a navigation the web app often does multiple requests to Next. If the access token has expired Next will do a request to Curity to renew the tokens. Since we only allow a single refresh token to be used only once only the first request will succeed and the following requests will fail.
To avoid that we have a component whose only purpose is to keep the access token alive. As long as no other request is happening at the same time this will work fine.
To avoid a session that keeps on refreshing forever, if the user have the page open in the background e.g., we have a timeout that stops the refreshing if the user is not active.

View File

@@ -5,6 +5,7 @@ import Script from "next/script"
import TrpcProvider from "@/lib/trpc/Provider"
import TokenRefresher from "@/components/Auth/TokenRefresher"
import AdobeSDKScript from "@/components/Current/AdobeSDKScript"
import Footer from "@/components/Current/Footer"
import VwoScript from "@/components/Current/VwoScript"
@@ -59,6 +60,7 @@ export default async function RootLayout({
{header}
{children}
<Footer />
<TokenRefresher />
</TrpcProvider>
</ServerIntlProvider>
</body>

View File

@@ -3,6 +3,7 @@ import "@scandic-hotels/design-system/style.css"
import Script from "next/script"
import TokenRefresher from "@/components/Auth/TokenRefresher"
import AdobeScript from "@/components/Current/AdobeScript"
import Footer from "@/components/Current/Footer"
import Header from "@/components/Current/Header"
@@ -72,6 +73,7 @@ export default async function RootLayout({
/>
{children}
<Footer />
<TokenRefresher />
</ServerIntlProvider>
<Script id="page-tracking">{`
typeof _satellite !== "undefined" && _satellite.pageBottom();

136
auth.ts
View File

@@ -1,10 +1,12 @@
import NextAuth from "next-auth"
import { PRE_REFRESH_TIME_IN_SECONDS } from "@/constants/auth"
import { env } from "@/env/server"
import { LoginTypeEnum } from "./types/components/tracking"
import type { NextAuthConfig, User } from "next-auth"
import type { JWT } from "next-auth/jwt"
import type { OIDCConfig } from "next-auth/providers"
function getLoginType(user: User) {
@@ -20,6 +22,66 @@ function getLoginType(user: User) {
}
}
async function refreshTokens(token: JWT) {
try {
console.log("token-debug Access token expired, trying to refresh it.", {
expires_at: token.expires_at,
sub: token.sub,
token: token.access_token,
})
const response = await fetch(`${env.CURITY_ISSUER_USER}/oauth/v2/token`, {
body: new URLSearchParams({
client_id: env.CURITY_CLIENT_ID_USER,
client_secret: env.CURITY_CLIENT_SECRET_USER,
grant_type: "refresh_token",
refresh_token: token.refresh_token,
}),
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
method: "POST",
})
const new_tokens = await response.json()
if (!response.ok) {
console.log("token-debug Token response was not ok", {
status: response.status,
statusText: response.statusText,
sub: token.sub,
})
throw new_tokens
}
console.log("token-debug Successfully got new token(s)", {
expires_at: new_tokens.expires_at,
got_new_refresh_token: new_tokens.refresh_token !== token.refresh_token,
got_new_access_token: new_tokens.access_token !== token.access_token,
sub: token.sub,
})
const expiresAt = new_tokens.expires_in
? Date.now() + new_tokens.expires_in * 1000
: undefined
return {
...token,
access_token: new_tokens.access_token,
expires_at: expiresAt,
refresh_token: new_tokens.refresh_token,
}
} catch (error) {
console.log("token-debug Error thrown when trying to refresh", {
error,
sub: token.sub,
})
return {
...token,
error: "RefreshAccessTokenError" as const,
}
}
}
const curityProvider = {
id: "curity",
name: "Curity",
@@ -103,7 +165,7 @@ export const config = {
async authorized({ auth, request }) {
return true
},
async jwt({ account, token, trigger, user, profile }) {
async jwt({ account, session, token, trigger, user, profile }) {
const loginType = getLoginType(user)
if (trigger === "signIn" && account) {
const mfa_scope =
@@ -121,71 +183,15 @@ export const config = {
mfa_scope: mfa_scope,
mfa_expires_at: mfa_expires_at,
}
} else if (Date.now() < token.expires_at) {
return token
} else {
try {
console.log(
"token-debug Access token expired, trying to refresh it.",
{
expires_at: token.expires_at,
sub: token.sub,
token: token.access_token,
}
)
const response = await fetch(
`${env.CURITY_ISSUER_USER}/oauth/v2/token`,
{
body: new URLSearchParams({
client_id: env.CURITY_CLIENT_ID_USER,
client_secret: env.CURITY_CLIENT_SECRET_USER,
grant_type: "refresh_token",
refresh_token: token.refresh_token,
}),
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
method: "POST",
}
)
const new_tokens = await response.json()
if (!response.ok) {
console.log("token-debug Token response was not ok", {
status: response.status,
statusText: response.statusText,
sub: token.sub,
})
throw new_tokens
}
console.log("token-debug Successfully got new token(s)", {
expires_at: new_tokens.expires_at,
got_new_refresh_token:
new_tokens.refresh_token !== token.refresh_token,
got_new_access_token:
new_tokens.access_token !== token.access_token,
sub: token.sub,
})
return {
...token,
access_token: new_tokens.access_token,
expires_at: new_tokens.expires_at,
refresh_token: new_tokens.refresh_token ?? token.refresh_token,
}
} catch (error) {
console.log("token-debug Error thrown when trying to refresh", {
error,
sub: token.sub,
})
return {
...token,
error: "RefreshAccessTokenError" as const,
}
}
} else if (
token.expires_at &&
Date.now() > token.expires_at - PRE_REFRESH_TIME_IN_SECONDS * 1000 &&
session?.doRefresh
) {
return refreshTokens(token)
}
return token
},
},
// events: {

View File

@@ -0,0 +1,65 @@
"use client"
import { usePathname, useSearchParams } from "next/navigation"
import { SessionProvider, useSession } from "next-auth/react"
import { useEffect, useMemo, useRef } from "react"
import {
MAX_KEEP_ALIVE_TIME_IN_MINUTES,
PRE_REFRESH_TIME_IN_SECONDS,
} from "@/constants/auth"
/**
* Keeps the access token alive by proactively refreshing it
*
*/
export default function TokenRefresher() {
return (
<SessionProvider basePath="/api/web/auth">
<Refresher />
</SessionProvider>
)
}
function Refresher() {
const session = useSession()
const pathname = usePathname()
const searchParams = useSearchParams()
const timeoutId = useRef<NodeJS.Timeout>()
// Simple inactivity control. Reset when the URL changes.
const stopPreRefreshAt = useMemo(
() => Date.now() + MAX_KEEP_ALIVE_TIME_IN_MINUTES * 60 * 1000,
// eslint-disable-next-line react-hooks/exhaustive-deps
[pathname, searchParams]
)
useEffect(() => {
if (timeoutId.current) {
clearTimeout(timeoutId.current)
}
if (!session.data?.token.expires_at) {
return
}
const refreshIn =
session.data.token.expires_at -
Date.now() -
PRE_REFRESH_TIME_IN_SECONDS * 1000
timeoutId.current = setTimeout(
async () => {
if (stopPreRefreshAt > Date.now()) {
await session.update({ doRefresh: true })
}
},
// If the token has already expired `refreshIn` will be
// negative, and we will refresh immediately (in 1 ms)
Math.max(refreshIn, 1)
)
return () => clearTimeout(timeoutId.current)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [session.data?.token.expires_at, stopPreRefreshAt])
return null
}

12
constants/auth.ts Normal file
View File

@@ -0,0 +1,12 @@
/**
* How many seconds before the expiry of the access token
* the client should refresh the tokens
*/
export const PRE_REFRESH_TIME_IN_SECONDS = 30
/**
* How many minutes the client should be allowed to be inactive
* and still get the tokens refreshed. The inactivity control
* gets reset when the URL changes.
*/
export const MAX_KEEP_ALIVE_TIME_IN_MINUTES = 2 * 60

View File

@@ -33,7 +33,7 @@ import type {
async function getVerifiedUser({ session }: { session: Session }) {
const now = Date.now()
if (session.token.expires_at < now) {
if (session.token.expires_at && session.token.expires_at < now) {
return { error: true, cause: "token_expired" } as const
}

2
types/jwt.d.ts vendored
View File

@@ -10,7 +10,7 @@ declare module "next-auth/jwt" {
/** Returned by the `jwt` callback and `auth`, when using JWT sessions */
interface JWT extends DefaultJWT, RefreshTokenError {
access_token: string
expires_at: number
expires_at?: number
refresh_token: string
loginType: LoginType
mfa_scope: boolean