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 TrpcProvider from "@/lib/trpc/Provider"
|
||||||
|
|
||||||
|
import TokenRefresher from "@/components/Auth/TokenRefresher"
|
||||||
import AdobeSDKScript from "@/components/Current/AdobeSDKScript"
|
import AdobeSDKScript from "@/components/Current/AdobeSDKScript"
|
||||||
import Footer from "@/components/Current/Footer"
|
import Footer from "@/components/Current/Footer"
|
||||||
import VwoScript from "@/components/Current/VwoScript"
|
import VwoScript from "@/components/Current/VwoScript"
|
||||||
@@ -59,6 +60,7 @@ export default async function RootLayout({
|
|||||||
{header}
|
{header}
|
||||||
{children}
|
{children}
|
||||||
<Footer />
|
<Footer />
|
||||||
|
<TokenRefresher />
|
||||||
</TrpcProvider>
|
</TrpcProvider>
|
||||||
</ServerIntlProvider>
|
</ServerIntlProvider>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import "@scandic-hotels/design-system/style.css"
|
|||||||
|
|
||||||
import Script from "next/script"
|
import Script from "next/script"
|
||||||
|
|
||||||
|
import TokenRefresher from "@/components/Auth/TokenRefresher"
|
||||||
import AdobeScript from "@/components/Current/AdobeScript"
|
import AdobeScript from "@/components/Current/AdobeScript"
|
||||||
import Footer from "@/components/Current/Footer"
|
import Footer from "@/components/Current/Footer"
|
||||||
import Header from "@/components/Current/Header"
|
import Header from "@/components/Current/Header"
|
||||||
@@ -72,6 +73,7 @@ export default async function RootLayout({
|
|||||||
/>
|
/>
|
||||||
{children}
|
{children}
|
||||||
<Footer />
|
<Footer />
|
||||||
|
<TokenRefresher />
|
||||||
</ServerIntlProvider>
|
</ServerIntlProvider>
|
||||||
<Script id="page-tracking">{`
|
<Script id="page-tracking">{`
|
||||||
typeof _satellite !== "undefined" && _satellite.pageBottom();
|
typeof _satellite !== "undefined" && _satellite.pageBottom();
|
||||||
|
|||||||
136
auth.ts
136
auth.ts
@@ -1,10 +1,12 @@
|
|||||||
import NextAuth from "next-auth"
|
import NextAuth from "next-auth"
|
||||||
|
|
||||||
|
import { PRE_REFRESH_TIME_IN_SECONDS } from "@/constants/auth"
|
||||||
import { env } from "@/env/server"
|
import { env } from "@/env/server"
|
||||||
|
|
||||||
import { LoginTypeEnum } from "./types/components/tracking"
|
import { LoginTypeEnum } from "./types/components/tracking"
|
||||||
|
|
||||||
import type { NextAuthConfig, User } from "next-auth"
|
import type { NextAuthConfig, User } from "next-auth"
|
||||||
|
import type { JWT } from "next-auth/jwt"
|
||||||
import type { OIDCConfig } from "next-auth/providers"
|
import type { OIDCConfig } from "next-auth/providers"
|
||||||
|
|
||||||
function getLoginType(user: User) {
|
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 = {
|
const curityProvider = {
|
||||||
id: "curity",
|
id: "curity",
|
||||||
name: "Curity",
|
name: "Curity",
|
||||||
@@ -103,7 +165,7 @@ export const config = {
|
|||||||
async authorized({ auth, request }) {
|
async authorized({ auth, request }) {
|
||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
async jwt({ account, token, trigger, user, profile }) {
|
async jwt({ account, session, token, trigger, user, profile }) {
|
||||||
const loginType = getLoginType(user)
|
const loginType = getLoginType(user)
|
||||||
if (trigger === "signIn" && account) {
|
if (trigger === "signIn" && account) {
|
||||||
const mfa_scope =
|
const mfa_scope =
|
||||||
@@ -121,71 +183,15 @@ export const config = {
|
|||||||
mfa_scope: mfa_scope,
|
mfa_scope: mfa_scope,
|
||||||
mfa_expires_at: mfa_expires_at,
|
mfa_expires_at: mfa_expires_at,
|
||||||
}
|
}
|
||||||
} else if (Date.now() < token.expires_at) {
|
} else if (
|
||||||
return token
|
token.expires_at &&
|
||||||
} else {
|
Date.now() > token.expires_at - PRE_REFRESH_TIME_IN_SECONDS * 1000 &&
|
||||||
try {
|
session?.doRefresh
|
||||||
console.log(
|
) {
|
||||||
"token-debug Access token expired, trying to refresh it.",
|
return refreshTokens(token)
|
||||||
{
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return token
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// events: {
|
// 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 }) {
|
async function getVerifiedUser({ session }: { session: Session }) {
|
||||||
const now = Date.now()
|
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
|
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 */
|
/** Returned by the `jwt` callback and `auth`, when using JWT sessions */
|
||||||
interface JWT extends DefaultJWT, RefreshTokenError {
|
interface JWT extends DefaultJWT, RefreshTokenError {
|
||||||
access_token: string
|
access_token: string
|
||||||
expires_at: number
|
expires_at?: number
|
||||||
refresh_token: string
|
refresh_token: string
|
||||||
loginType: LoginType
|
loginType: LoginType
|
||||||
mfa_scope: boolean
|
mfa_scope: boolean
|
||||||
|
|||||||
Reference in New Issue
Block a user