feat(WEB-215): add refresh_token

This commit is contained in:
Simon Emanuelsson
2024-04-24 12:37:47 +02:00
committed by Michael Zetterberg
parent 68f1e87169
commit c4912bbb94
10 changed files with 175 additions and 21 deletions

View File

@@ -0,0 +1,22 @@
"use client"
import { useParams } from "next/navigation"
import { useEffect } from "react"
import { login } from "@/constants/routes/handleAuth"
import { SESSION_EXPIRED } from "@/server/errors/trpc"
import type { ErrorPage } from "@/types/next/error"
import type { LangParams } from "@/types/params"
export default function ProtectedError({ error }: ErrorPage) {
const params = useParams<LangParams>()
useEffect(() => {
if (error.message === SESSION_EXPIRED) {
const loginUrl = login[params.lang]
window.location.assign(loginUrl)
}
}, [error.message, params.lang])
return null
}

View File

@@ -43,7 +43,7 @@ export default async function RootLayout({
<VwoScript />
</head>
<body>
<TrpcProvider>{children}</TrpcProvider>
<TrpcProvider lang={params.lang}>{children}</TrpcProvider>
<Script id="page-tracking">{`
typeof _satellite !== "undefined" && _satellite.pageBottom();
`}</Script>

48
auth.ts
View File

@@ -55,7 +55,8 @@ export const config = {
async signIn() {
return true
},
async session({ session, token, user }) {
async session({ session, token }) {
session.error = token.error
if (session.user) {
return {
...session,
@@ -95,13 +96,54 @@ export const config = {
async authorized({ auth, request }) {
return true
},
async jwt({ session, token, trigger, account }) {
async jwt({ account, session, token, trigger }) {
if (account) {
return {
access_token: account.access_token,
expires_at: account.expires_at
? account.expires_at * 1000
: undefined,
refresh_token: account.refresh_token,
}
} else if (Date.now() < token.expires_at) {
return token
} else {
try {
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 tokens = await response.json()
if (!response.ok) {
throw tokens
}
return {
...token,
access_token: tokens.access_token,
expires_at: tokens.expires_at,
refresh_token: tokens.refresh_token ?? token.refresh_token,
}
} catch (error) {
return {
...token,
error: "RefreshAccessTokenError" as const,
}
}
}
return token
},
},
// events: {

View File

@@ -1,14 +1,23 @@
"use client"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { httpBatchLink, loggerLink } from "@trpc/client"
import {
QueryCache,
QueryClient,
QueryClientProvider,
} from "@tanstack/react-query"
import { httpBatchLink, loggerLink,TRPCClientError } from "@trpc/client"
import { AnyTRPCRouter } from "@trpc/server"
import { useState } from "react"
import { login } from "@/constants/routes/handleAuth"
import { env } from "@/env/client"
import { SessionExpiredError } from "@/server/errors/trpc"
import { transformer } from "@/server/transformer"
import { trpc } from "./client"
import { LangParams } from "@/types/params"
function initializeTrpcClient() {
// Locally we set nextjs to run on port to 3000 so that we always guarantee
// that trpc and next are running on the same port.
@@ -32,8 +41,58 @@ function initializeTrpcClient() {
})
}
export default function TrpcProvider({ children }: React.PropsWithChildren) {
const [queryClient] = useState(() => new QueryClient({}))
export default function TrpcProvider({
children,
lang,
}: React.PropsWithChildren<LangParams>) {
const [queryClient] = useState(
() =>
new QueryClient({
queryCache: new QueryCache({
async onError(error) {
if (error instanceof TRPCClientError) {
const appError: TRPCClientError<AnyTRPCRouter> = error
console.log({ appError })
if (appError.data?.code === "UNAUTHORIZED") {
if (appError.data?.cause instanceof SessionExpiredError) {
const loginUrl = login[lang]
window.location.assign(loginUrl)
}
}
}
},
}),
defaultOptions: {
queries: {
staleTime: 3000,
retry(failureCount, error) {
if (error instanceof TRPCClientError) {
const appError: TRPCClientError<AnyTRPCRouter> = error
// Do not retry query requests that got UNAUTHORIZED error.
// It won't make a difference sending the same request again.
if (appError.data?.code) {
if (
[
"UNAUTHORIZED",
"INTERNAL_SERVER_ERROR",
"FORBIDDEN",
].includes(appError.data.code)
) {
return false
}
}
}
// Retry all client requests that fail (and are not handled above)
// at most 3 times.
return failureCount < 3
},
},
},
})
)
const [trpcClient] = useState(() => initializeTrpcClient())
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>

View File

@@ -42,6 +42,11 @@ export const middleware = auth(async (request) => {
const lang = findLang(nextUrl.pathname)!
const isLoggedIn = !!request.auth
const hasError = request.auth?.error
if (hasError) {
throw internalServerError(request.auth?.error)
}
if (isLoggedIn) {
const headers = new Headers(request.headers)

View File

@@ -39,3 +39,13 @@ export function internalServerError(cause?: unknown) {
cause,
})
}
export const SESSION_EXPIRED = "SESSION_EXPIRED"
export class SessionExpiredError extends Error {}
export function sessionExpiredError() {
return new TRPCError({
code: "UNAUTHORIZED",
message: SESSION_EXPIRED,
cause: new SessionExpiredError(SESSION_EXPIRED),
})
}

View File

@@ -34,6 +34,10 @@ export const protectedProcedure = t.procedure.use(async function (opts) {
console.info(`path: ${opts.path} | type: ${opts.type}`)
}
if (session?.error === "RefreshAccessTokenError") {
throw unauthorizedError()
}
if (!session?.user) {
throw unauthorizedError()
}

27
types/auth.d.ts vendored
View File

@@ -1,8 +1,23 @@
import type { JWT } from "next-auth/jwt"
import type { RefreshTokenError } from "./authError"
// Module augmentation
// https://authjs.dev/getting-started/typescript#popular-interfaces-to-augment
declare module "next-auth" {
/**
* The shape of the account object returned in the OAuth providers' `account` callback,
* Usually contains information about the provider being used, like OAuth tokens (`access_token`, etc).
*/
interface Account {}
/**
* Returned by `useSession`, `auth`, contains information about the active session.
*/
interface Session extends RefreshTokenError {
token: JWT
}
/**
* The shape of the user object returned in the OAuth providers' `profile` callback,
* or the second parameter of the `session` callback, when using a database.
@@ -11,16 +26,4 @@ declare module "next-auth" {
given_name: string
sub: string
}
/**
* The shape of the account object returned in the OAuth providers' `account` callback,
* Usually contains information about the provider being used, like OAuth tokens (`access_token`, etc).
*/
interface Account { }
/**
* Returned by `useSession`, `auth`, contains information about the active session.
*/
interface Session {
token: JWT
}
}

3
types/authError.ts Normal file
View File

@@ -0,0 +1,3 @@
export interface RefreshTokenError {
error?: "RefreshAccessTokenError"
}

8
types/jwt.d.ts vendored
View File

@@ -1,8 +1,14 @@
import type { DefaultJWT } from "next-auth/jwt"
import type { RefreshTokenError } from "./authError"
// Module augmentation
// https://authjs.dev/getting-started/typescript#popular-interfaces-to-augment
declare module "next-auth/jwt" {
/** Returned by the `jwt` callback and `auth`, when using JWT sessions */
interface JWT {
interface JWT extends DefaultJWT, RefreshTokenError {
access_token: string
expires_at: number
refresh_token: string
}
}