Merged in feature/refresh-token (pull request #401)
feat: SW-101 Proactively refresh tokens Approved-by: Michael Zetterberg
This commit is contained in:
13
Auth.md
Normal file
13
Auth.md
Normal 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.
|
||||
@@ -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>
|
||||
|
||||
@@ -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
136
auth.ts
@@ -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: {
|
||||
|
||||
65
components/Auth/TokenRefresher.tsx
Normal file
65
components/Auth/TokenRefresher.tsx
Normal 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
12
constants/auth.ts
Normal 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
|
||||
@@ -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
2
types/jwt.d.ts
vendored
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user