401 reauth experiments

This commit is contained in:
Igor Loskutov
2025-09-05 14:20:00 -04:00
parent 01c969b8a9
commit 2e94f4ccbe
4 changed files with 79 additions and 14 deletions

View File

@@ -1,9 +1,9 @@
"use client"; "use client";
import { createContext, useContext } from "react"; import { createContext, useContext, useEffect } from "react";
import { useSession as useNextAuthSession } from "next-auth/react"; import { useSession as useNextAuthSession } from "next-auth/react";
import { signOut, signIn } 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 { assertCustomSession, CustomSession } from "./types";
import { Session } from "next-auth"; import { Session } from "next-auth";
import { SessionAutoRefresh } from "./SessionAutoRefresh"; import { SessionAutoRefresh } from "./SessionAutoRefresh";
@@ -88,6 +88,12 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
contextValue.status === "authenticated" ? contextValue.accessToken : null, contextValue.status === "authenticated" ? contextValue.accessToken : null,
); );
useEffect(() => {
configureApiAuthRefresh(
contextValue.status === "authenticated" ? contextValue.update : null,
);
}, [contextValue.status === "authenticated" && contextValue.update]);
return ( return (
<AuthContext.Provider value={contextValue}> <AuthContext.Provider value={contextValue}>
<SessionAutoRefresh>{children}</SessionAutoRefresh> <SessionAutoRefresh>{children}</SessionAutoRefresh>

View File

@@ -25,15 +25,16 @@ export function SessionAutoRefresh({ children }) {
const interval = setInterval(() => { const interval = setInterval(() => {
if (accessTokenExpires !== null) { if (accessTokenExpires !== null) {
const timeLeft = accessTokenExpires - Date.now(); const timeLeft = accessTokenExpires - Date.now();
if (timeLeft < REFRESH_BEFORE) { console.log("time left", timeLeft);
auth // if (timeLeft < REFRESH_BEFORE) {
.update() // auth
.then(() => {}) // .update()
.catch((e) => { // .then(() => {})
// note: 401 won't be considered error here // .catch((e) => {
console.error("error refreshing auth token", e); // // note: 401 won't be considered error here
}); // console.error("error refreshing auth token", e);
} // });
// }
} }
}, INTERVAL_REFRESH_MS); }, INTERVAL_REFRESH_MS);

View File

@@ -11,6 +11,9 @@ import {
import createFetchClient from "openapi-react-query"; import createFetchClient from "openapi-react-query";
import { assertExistsAndNonEmptyString } from "./utils"; import { assertExistsAndNonEmptyString } from "./utils";
import { isBuildPhase } from "./next"; import { isBuildPhase } from "./next";
import { Session } from "next-auth";
import { assertCustomSession } from "./types";
import { HttpMethod, PathsWithMethod } from "openapi-typescript-helpers";
const API_URL = !isBuildPhase const API_URL = !isBuildPhase
? assertExistsAndNonEmptyString(process.env.NEXT_PUBLIC_API_URL) ? assertExistsAndNonEmptyString(process.env.NEXT_PUBLIC_API_URL)
@@ -25,12 +28,20 @@ export const client = createClient<paths>({
export const $api = createFetchClient<paths>(client); export const $api = createFetchClient<paths>(client);
let currentAuthToken: string | null | undefined = null; let currentAuthToken: string | null | undefined = null;
let refreshAuthCallback: (() => Promise<Session | null>) | 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({ client.use({
onRequest({ request }) { onRequest({ request }) {
if (currentAuthToken) { request = injectAuth(request, currentAuthToken || null);
request.headers.set("Authorization", `Bearer ${currentAuthToken}`);
}
// XXX Only set Content-Type if not already set (FormData will set its own boundary) // 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 // This is a work around for uploading file, we're passing a formdata
// but the content type was still application/json // 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<paths, HttpMethod>,
...params,
);
return r.response;
} catch (error) {
console.error("Token refresh failed during 401 retry:", error);
}
}
return response;
},
});
// the function contract: lightweight, idempotent // the function contract: lightweight, idempotent
export const configureApiAuth = (token: string | null | undefined) => { export const configureApiAuth = (token: string | null | undefined) => {
currentAuthToken = token; currentAuthToken = token;
}; };
export const configureApiAuthRefresh = (
callback: (() => Promise<Session | null>) | null,
) => {
refreshAuthCallback = callback;
};

View File

@@ -45,6 +45,7 @@ export const authOptions: AuthOptions = {
}, },
callbacks: { callbacks: {
async jwt({ token, account, user }) { async jwt({ token, account, user }) {
console.log("token.sub jwt callback", token.sub);
const KEY = `token:${token.sub}`; const KEY = `token:${token.sub}`;
if (account && user) { if (account && user) {
@@ -70,6 +71,13 @@ export const authOptions: AuthOptions = {
} }
const currentToken = await getTokenCache(tokenCacheRedis, KEY); 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) { if (currentToken && Date.now() < currentToken.token.accessTokenExpires) {
return currentToken.token; return currentToken.token;
} }