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";
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 (
<AuthContext.Provider value={contextValue}>
<SessionAutoRefresh>{children}</SessionAutoRefresh>

View File

@@ -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);

View File

@@ -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<paths>({
export const $api = createFetchClient<paths>(client);
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({
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<paths, HttpMethod>,
...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<Session | null>) | null,
) => {
refreshAuthCallback = callback;
};

View File

@@ -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;
}