diff --git a/app/[lang]/(live)/(protected)/error.tsx b/app/[lang]/(live)/(protected)/error.tsx new file mode 100644 index 000000000..ed96c6ec6 --- /dev/null +++ b/app/[lang]/(live)/(protected)/error.tsx @@ -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() + + useEffect(() => { + if (error.message === SESSION_EXPIRED) { + const loginUrl = login[params.lang] + window.location.assign(loginUrl) + } + }, [error.message, params.lang]) + + return null +} diff --git a/app/[lang]/(live)/layout.tsx b/app/[lang]/(live)/layout.tsx index 921f5a8a0..34e4cf3b5 100644 --- a/app/[lang]/(live)/layout.tsx +++ b/app/[lang]/(live)/layout.tsx @@ -43,7 +43,7 @@ export default async function RootLayout({ - {children} + {children} diff --git a/auth.ts b/auth.ts index 3436d544b..59e85c82d 100644 --- a/auth.ts +++ b/auth.ts @@ -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: { diff --git a/lib/trpc/Provider.tsx b/lib/trpc/Provider.tsx index 4f755ae48..78576e492 100644 --- a/lib/trpc/Provider.tsx +++ b/lib/trpc/Provider.tsx @@ -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) { + const [queryClient] = useState( + () => + new QueryClient({ + queryCache: new QueryCache({ + async onError(error) { + if (error instanceof TRPCClientError) { + const appError: TRPCClientError = 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 = 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 ( diff --git a/middlewares/authRequired.ts b/middlewares/authRequired.ts index 3eecd0289..1332d4bc8 100644 --- a/middlewares/authRequired.ts +++ b/middlewares/authRequired.ts @@ -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) diff --git a/server/errors/trpc.ts b/server/errors/trpc.ts index 12ef04df9..0acf19819 100644 --- a/server/errors/trpc.ts +++ b/server/errors/trpc.ts @@ -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), + }) +} diff --git a/server/trpc.ts b/server/trpc.ts index 54a4d7f68..66312f01e 100644 --- a/server/trpc.ts +++ b/server/trpc.ts @@ -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() } diff --git a/types/auth.d.ts b/types/auth.d.ts index 1b06c1234..3cf582207 100644 --- a/types/auth.d.ts +++ b/types/auth.d.ts @@ -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 - } } diff --git a/types/authError.ts b/types/authError.ts new file mode 100644 index 000000000..2c2217908 --- /dev/null +++ b/types/authError.ts @@ -0,0 +1,3 @@ +export interface RefreshTokenError { + error?: "RefreshAccessTokenError" +} diff --git a/types/jwt.d.ts b/types/jwt.d.ts index 97fa95704..b1dbc1af8 100644 --- a/types/jwt.d.ts +++ b/types/jwt.d.ts @@ -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 } }