diff --git a/www/app/lib/AuthProvider.tsx b/www/app/lib/AuthProvider.tsx index 96f49f87..7093aae4 100644 --- a/www/app/lib/AuthProvider.tsx +++ b/www/app/lib/AuthProvider.tsx @@ -1,9 +1,9 @@ "use client"; -import { createContext, useContext } from "react"; +import { createContext, useContext, useEffect } from "react"; import { useSession as useNextAuthSession } from "next-auth/react"; import { signOut, signIn } from "next-auth/react"; -import { configureApiAuth } from "./apiClient"; +import { configureApiAuth, configureApiAuthRefresh } from "./apiClient"; import { assertCustomSession, CustomSession } from "./types"; import { Session } from "next-auth"; import { SessionAutoRefresh } from "./SessionAutoRefresh"; @@ -88,6 +88,12 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { contextValue.status === "authenticated" ? contextValue.accessToken : null, ); + useEffect(() => { + configureApiAuthRefresh( + contextValue.status === "authenticated" ? contextValue.update : null, + ); + }, [contextValue.status === "authenticated" && contextValue.update]); + return ( {children} diff --git a/www/app/lib/SessionAutoRefresh.tsx b/www/app/lib/SessionAutoRefresh.tsx index fd29367f..316d0ce5 100644 --- a/www/app/lib/SessionAutoRefresh.tsx +++ b/www/app/lib/SessionAutoRefresh.tsx @@ -25,15 +25,16 @@ export function SessionAutoRefresh({ children }) { const interval = setInterval(() => { if (accessTokenExpires !== null) { const timeLeft = accessTokenExpires - Date.now(); - if (timeLeft < REFRESH_BEFORE) { - auth - .update() - .then(() => {}) - .catch((e) => { - // note: 401 won't be considered error here - console.error("error refreshing auth token", e); - }); - } + console.log("time left", timeLeft); + // if (timeLeft < REFRESH_BEFORE) { + // auth + // .update() + // .then(() => {}) + // .catch((e) => { + // // note: 401 won't be considered error here + // console.error("error refreshing auth token", e); + // }); + // } } }, INTERVAL_REFRESH_MS); diff --git a/www/app/lib/apiClient.tsx b/www/app/lib/apiClient.tsx index cd97e151..7d6ed17b 100644 --- a/www/app/lib/apiClient.tsx +++ b/www/app/lib/apiClient.tsx @@ -11,6 +11,9 @@ import { import createFetchClient from "openapi-react-query"; import { assertExistsAndNonEmptyString } from "./utils"; import { isBuildPhase } from "./next"; +import { Session } from "next-auth"; +import { assertCustomSession } from "./types"; +import { HttpMethod, PathsWithMethod } from "openapi-typescript-helpers"; const API_URL = !isBuildPhase ? assertExistsAndNonEmptyString(process.env.NEXT_PUBLIC_API_URL) @@ -25,12 +28,20 @@ export const client = createClient({ export const $api = createFetchClient(client); let currentAuthToken: string | null | undefined = null; +let refreshAuthCallback: (() => Promise) | null = null; + +const injectAuth = (request: Request, accessToken: string | null) => { + if (accessToken) { + request.headers.set("Authorization", `Bearer ${currentAuthToken}`); + } else { + request.headers.delete("Authorization"); + } + return request; +}; client.use({ onRequest({ request }) { - if (currentAuthToken) { - request.headers.set("Authorization", `Bearer ${currentAuthToken}`); - } + request = injectAuth(request, currentAuthToken || null); // XXX Only set Content-Type if not already set (FormData will set its own boundary) // This is a work around for uploading file, we're passing a formdata // but the content type was still application/json @@ -44,7 +55,46 @@ client.use({ }, }); +client.use({ + async onResponse({ response, request, params, schemaPath }) { + if (response.status === 401) { + console.log( + "response.status is 401!", + refreshAuthCallback, + request, + schemaPath, + ); + } + if (response.status === 401 && refreshAuthCallback) { + try { + const session = await refreshAuthCallback(); + if (!session) { + console.warn("Token refresh failed, no session returned"); + return response; + } + const customSession = assertCustomSession(session); + currentAuthToken = customSession.accessToken; + const r = await client.request( + request.method as HttpMethod, + schemaPath as PathsWithMethod, + ...params, + ); + return r.response; + } catch (error) { + console.error("Token refresh failed during 401 retry:", error); + } + } + return response; + }, +}); + // the function contract: lightweight, idempotent export const configureApiAuth = (token: string | null | undefined) => { currentAuthToken = token; }; + +export const configureApiAuthRefresh = ( + callback: (() => Promise) | null, +) => { + refreshAuthCallback = callback; +}; diff --git a/www/app/lib/authBackend.ts b/www/app/lib/authBackend.ts index af93b274..472444d2 100644 --- a/www/app/lib/authBackend.ts +++ b/www/app/lib/authBackend.ts @@ -45,6 +45,7 @@ export const authOptions: AuthOptions = { }, callbacks: { async jwt({ token, account, user }) { + console.log("token.sub jwt callback", token.sub); const KEY = `token:${token.sub}`; if (account && user) { @@ -70,6 +71,13 @@ export const authOptions: AuthOptions = { } const currentToken = await getTokenCache(tokenCacheRedis, KEY); + console.log( + "currentToken.token.accessTokenExpires", + currentToken?.token?.accessTokenExpires, + currentToken?.token?.accessTokenExpires + ? Date.now() < currentToken?.token?.accessTokenExpires + : "?", + ); if (currentToken && Date.now() < currentToken.token.accessTokenExpires) { return currentToken.token; }