protect from zombie auth

This commit is contained in:
Igor Loskutov
2025-09-03 10:53:03 -04:00
parent 611e258d96
commit 0cbbd24c65
9 changed files with 222 additions and 213 deletions

View File

@@ -26,6 +26,7 @@ const TranscriptCreate = () => {
const router = useRouter();
const auth = useAuth();
const isAuthenticated = auth.status === "authenticated";
const isAuthRefreshing = auth.status === "refreshing";
const isLoading = auth.status === "loading";
const requireLogin = featureEnabled("requireLogin");
@@ -133,7 +134,7 @@ const TranscriptCreate = () => {
<Center>
{isLoading ? (
<Spinner />
) : requireLogin && !isAuthenticated ? (
) : requireLogin && !isAuthenticated && !isAuthRefreshing ? (
<Button onClick={() => auth.signIn("authentik")}>Log in</Button>
) : (
<Flex

View File

@@ -1,5 +1,5 @@
import NextAuth from "next-auth";
import { authOptions } from "../../../lib/auth";
import { authOptions } from "../../../lib/authBackend";
const handler = NextAuth(authOptions);

View File

@@ -4,9 +4,10 @@ import { createContext, useContext } from "react";
import { useSession as useNextAuthSession } from "next-auth/react";
import { signOut, signIn } from "next-auth/react";
import { configureApiAuth } from "./apiClient";
import { assertExtendedTokenAndUserId, CustomSession } from "./types";
import { assertCustomSession, CustomSession } from "./types";
import { Session } from "next-auth";
import { SessionAutoRefresh } from "./SessionAutoRefresh";
import { REFRESH_ACCESS_TOKEN_ERROR } from "./auth";
type AuthContextType = (
| { status: "loading" }
@@ -28,29 +29,31 @@ const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const { data: session, status, update } = useNextAuthSession();
const customSession = session ? assertExtendedTokenAndUserId(session) : null;
console.log("customSessioncustomSession", customSession);
const customSession = session ? assertCustomSession(session) : null;
const contextValue: AuthContextType = {
...(status === "loading" && !customSession
? { status }
: status === "loading" && customSession
? { status: "refreshing" as const }
: status === "authenticated" && customSession?.accessToken
? {
status,
accessToken: customSession.accessToken,
accessTokenExpires: customSession.accessTokenExpires,
user: customSession.user,
}
: status === "authenticated" && !customSession?.accessToken
? (() => {
console.warn(
"illegal state: authenticated but have no session/or access token. ignoring",
);
return { status: "unauthenticated" as const };
})()
: { status: "unauthenticated" as const }),
: status === "authenticated" &&
customSession?.error === REFRESH_ACCESS_TOKEN_ERROR
? { status: "unauthenticated" }
: status === "authenticated" && customSession?.accessToken
? {
status,
accessToken: customSession.accessToken,
accessTokenExpires: customSession.accessTokenExpires,
user: customSession.user,
}
: status === "authenticated" && !customSession?.accessToken
? (() => {
console.warn(
"illegal state: authenticated but have no session/or access token. ignoring",
);
return { status: "unauthenticated" as const };
})()
: { status: "unauthenticated" as const }),
update,
signIn,
signOut,

View File

@@ -12,12 +12,12 @@ import { useAuth } from "./AuthProvider";
export function SessionAutoRefresh({
children,
refreshInterval = 20 /* seconds */,
refreshInterval = 5 /* seconds */,
}) {
const auth = useAuth();
const accessTokenExpires =
auth.status === "authenticated" ? auth.accessTokenExpires : null;
console.log("authauth", auth);
const refreshIntervalMs = refreshInterval * 1000;
useEffect(() => {
@@ -25,7 +25,13 @@ export function SessionAutoRefresh({
if (accessTokenExpires !== null) {
const timeLeft = accessTokenExpires - Date.now();
if (timeLeft < refreshIntervalMs) {
auth.update();
auth
.update()
.then(() => {})
.catch((e) => {
// note: 401 won't be considered error here
console.error("error refreshing auth token", e);
});
}
}
}, refreshIntervalMs);

View File

@@ -19,9 +19,6 @@ export const client = createClient<paths>({
export const $api = createFetchClient<paths>(client);
let currentAuthToken: string | null | undefined = null;
let authConfigured = false;
export const isAuthConfigured = () => authConfigured;
client.use({
onRequest({ request }) {
@@ -44,9 +41,4 @@ client.use({
// the function contract: lightweight, idempotent
export const configureApiAuth = (token: string | null | undefined) => {
currentAuthToken = token;
authConfigured = true;
};
export const useApiQuery = $api.useQuery;
export const useApiMutation = $api.useMutation;
export const useApiSuspenseQuery = $api.useSuspenseQuery;

View File

@@ -49,8 +49,6 @@ export function useTranscriptsSearch(
source_kind?: SourceKind;
} = {},
) {
const { isAuthenticated } = useAuthReady();
return $api.useQuery(
"get",
"/v1/transcripts/search",
@@ -66,7 +64,7 @@ export function useTranscriptsSearch(
},
},
{
enabled: isAuthenticated,
enabled: true, // anonymous enabled
},
);
}

View File

@@ -1,178 +1 @@
import { AuthOptions } from "next-auth";
import AuthentikProvider from "next-auth/providers/authentik";
import type { JWT } from "next-auth/jwt";
import { JWTWithAccessToken, CustomSession } from "./types";
import {
assertExists,
assertExistsAndNonEmptyString,
parseMaybeNonEmptyString,
} from "./utils";
const PRETIMEOUT = 600;
const tokenCache = new Map<
string,
{ token: JWTWithAccessToken; timestamp: number }
>();
const TOKEN_CACHE_TTL = 60 * 60 * 24 * 30 * 1000; // 30 days in milliseconds
const refreshLocks = new Map<string, Promise<JWTWithAccessToken>>();
const CLIENT_ID = assertExistsAndNonEmptyString(
process.env.AUTHENTIK_CLIENT_ID,
);
const CLIENT_SECRET = assertExistsAndNonEmptyString(
process.env.AUTHENTIK_CLIENT_SECRET,
);
export const authOptions: AuthOptions = {
providers: [
AuthentikProvider({
clientId: CLIENT_ID,
clientSecret: CLIENT_SECRET,
issuer: process.env.AUTHENTIK_ISSUER,
authorization: {
params: {
scope: "openid email profile offline_access",
},
},
}),
],
session: {
strategy: "jwt",
},
callbacks: {
async jwt({ token, account, user }) {
const KEY = `token:${token.sub}`;
if (account && user) {
// called only on first login
// XXX account.expires_in used in example is not defined for authentik backend, but expires_at is
const expiresAtS = assertExists(account.expires_at) - PRETIMEOUT;
const expiresAtMs = expiresAtS * 1000;
if (!account.access_token) {
tokenCache.delete(KEY);
} else {
const jwtToken: JWTWithAccessToken = {
...token,
accessToken: account.access_token,
accessTokenExpires: expiresAtMs,
refreshToken: account.refresh_token || "",
};
// Store in memory cache
tokenCache.set(KEY, {
token: jwtToken,
timestamp: Date.now(),
});
return jwtToken;
}
}
const currentToken = tokenCache.get(KEY);
if (currentToken && Date.now() < currentToken.token.accessTokenExpires) {
return currentToken.token;
}
// access token has expired, try to update it
return await lockedRefreshAccessToken(token);
},
async session({ session, token }) {
const extendedToken = token as JWTWithAccessToken;
return {
...session,
accessToken: extendedToken.accessToken,
accessTokenExpires: extendedToken.accessTokenExpires,
error: extendedToken.error,
user: {
id: assertExists(extendedToken.sub),
name: extendedToken.name,
email: extendedToken.email,
},
} satisfies CustomSession;
},
},
};
async function lockedRefreshAccessToken(
token: JWT,
): Promise<JWTWithAccessToken> {
const lockKey = `${token.sub}-refresh`;
const existingRefresh = refreshLocks.get(lockKey);
if (existingRefresh) {
return await existingRefresh;
}
const refreshPromise = (async () => {
try {
const cached = tokenCache.get(`token:${token.sub}`);
if (cached) {
if (Date.now() - cached.timestamp > TOKEN_CACHE_TTL) {
tokenCache.delete(`token:${token.sub}`);
} else if (Date.now() < cached.token.accessTokenExpires) {
return cached.token;
}
}
const currentToken = cached?.token || (token as JWTWithAccessToken);
const newToken = await refreshAccessToken(currentToken);
tokenCache.set(`token:${token.sub}`, {
token: newToken,
timestamp: Date.now(),
});
return newToken;
} finally {
setTimeout(() => refreshLocks.delete(lockKey), 100);
}
})();
refreshLocks.set(lockKey, refreshPromise);
return refreshPromise;
}
async function refreshAccessToken(token: JWT): Promise<JWTWithAccessToken> {
try {
const url = `${process.env.AUTHENTIK_REFRESH_TOKEN_URL}`;
const options = {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
client_id: process.env.AUTHENTIK_CLIENT_ID as string,
client_secret: process.env.AUTHENTIK_CLIENT_SECRET as string,
grant_type: "refresh_token",
refresh_token: token.refreshToken as string,
}).toString(),
method: "POST",
};
const response = await fetch(url, options);
if (!response.ok) {
console.error(
new Date().toISOString(),
"Failed to refresh access token. Response status:",
response.status,
);
const responseBody = await response.text();
console.error(new Date().toISOString(), "Response body:", responseBody);
throw new Error(`Failed to refresh access token: ${response.statusText}`);
}
const refreshedTokens = await response.json();
return {
...token,
accessToken: refreshedTokens.access_token,
accessTokenExpires:
Date.now() + (refreshedTokens.expires_in - PRETIMEOUT) * 1000,
refreshToken: refreshedTokens.refresh_token,
};
} catch (error) {
console.error("Error refreshing access token", error);
return {
...token,
error: "RefreshAccessTokenError",
} as JWTWithAccessToken;
}
}
export const REFRESH_ACCESS_TOKEN_ERROR = "RefreshAccessTokenError" as const;

179
www/app/lib/authBackend.ts Normal file
View File

@@ -0,0 +1,179 @@
import { AuthOptions } from "next-auth";
import AuthentikProvider from "next-auth/providers/authentik";
import type { JWT } from "next-auth/jwt";
import { JWTWithAccessToken, CustomSession } from "./types";
import {
assertExists,
assertExistsAndNonEmptyString,
parseMaybeNonEmptyString,
} from "./utils";
import { REFRESH_ACCESS_TOKEN_ERROR } from "./auth";
const PRETIMEOUT = 600;
const tokenCache = new Map<
string,
{ token: JWTWithAccessToken; timestamp: number }
>();
const TOKEN_CACHE_TTL = 60 * 60 * 24 * 30 * 1000; // 30 days in milliseconds
const refreshLocks = new Map<string, Promise<JWTWithAccessToken>>();
const CLIENT_ID = assertExistsAndNonEmptyString(
process.env.AUTHENTIK_CLIENT_ID,
);
const CLIENT_SECRET = assertExistsAndNonEmptyString(
process.env.AUTHENTIK_CLIENT_SECRET,
);
export const authOptions: AuthOptions = {
providers: [
AuthentikProvider({
clientId: CLIENT_ID,
clientSecret: CLIENT_SECRET,
issuer: process.env.AUTHENTIK_ISSUER,
authorization: {
params: {
scope: "openid email profile offline_access",
},
},
}),
],
session: {
strategy: "jwt",
},
callbacks: {
async jwt({ token, account, user }) {
const KEY = `token:${token.sub}`;
if (account && user) {
// called only on first login
// XXX account.expires_in used in example is not defined for authentik backend, but expires_at is
const expiresAtS = assertExists(account.expires_at) - PRETIMEOUT;
const expiresAtMs = expiresAtS * 1000;
if (!account.access_token) {
tokenCache.delete(KEY);
} else {
const jwtToken: JWTWithAccessToken = {
...token,
accessToken: account.access_token,
accessTokenExpires: expiresAtMs,
refreshToken: account.refresh_token,
};
// Store in memory cache
tokenCache.set(KEY, {
token: jwtToken,
timestamp: Date.now(),
});
return jwtToken;
}
}
const currentToken = tokenCache.get(KEY);
if (currentToken && Date.now() < currentToken.token.accessTokenExpires) {
return currentToken.token;
}
// access token has expired, try to update it
return await lockedRefreshAccessToken(token);
},
async session({ session, token }) {
const extendedToken = token as JWTWithAccessToken;
return {
...session,
accessToken: extendedToken.accessToken,
accessTokenExpires: extendedToken.accessTokenExpires,
error: extendedToken.error,
user: {
id: assertExists(extendedToken.sub),
name: extendedToken.name,
email: extendedToken.email,
},
} satisfies CustomSession;
},
},
};
async function lockedRefreshAccessToken(
token: JWT,
): Promise<JWTWithAccessToken> {
const lockKey = `${token.sub}-refresh`;
const existingRefresh = refreshLocks.get(lockKey);
if (existingRefresh) {
return await existingRefresh;
}
const refreshPromise = (async () => {
try {
const cached = tokenCache.get(`token:${token.sub}`);
if (cached) {
if (Date.now() - cached.timestamp > TOKEN_CACHE_TTL) {
tokenCache.delete(`token:${token.sub}`);
} else if (Date.now() < cached.token.accessTokenExpires) {
return cached.token;
}
}
const currentToken = cached?.token || (token as JWTWithAccessToken);
const newToken = await refreshAccessToken(currentToken);
tokenCache.set(`token:${token.sub}`, {
token: newToken,
timestamp: Date.now(),
});
return newToken;
} finally {
setTimeout(() => refreshLocks.delete(lockKey), 100);
}
})();
refreshLocks.set(lockKey, refreshPromise);
return refreshPromise;
}
async function refreshAccessToken(token: JWT): Promise<JWTWithAccessToken> {
try {
const url = `${process.env.AUTHENTIK_REFRESH_TOKEN_URL}`;
const options = {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
client_id: process.env.AUTHENTIK_CLIENT_ID as string,
client_secret: process.env.AUTHENTIK_CLIENT_SECRET as string,
grant_type: "refresh_token",
refresh_token: token.refreshToken as string,
}).toString(),
method: "POST",
};
const response = await fetch(url, options);
if (!response.ok) {
console.error(
new Date().toISOString(),
"Failed to refresh access token. Response status:",
response.status,
);
const responseBody = await response.text();
console.error(new Date().toISOString(), "Response body:", responseBody);
throw new Error(`Failed to refresh access token: ${response.statusText}`);
}
const refreshedTokens = await response.json();
return {
...token,
accessToken: refreshedTokens.access_token,
accessTokenExpires:
Date.now() + (refreshedTokens.expires_in - PRETIMEOUT) * 1000,
refreshToken: refreshedTokens.refresh_token,
};
} catch (error) {
console.error("Error refreshing access token", error);
return {
...token,
error: REFRESH_ACCESS_TOKEN_ERROR,
} as JWTWithAccessToken;
}
}

View File

@@ -5,7 +5,7 @@ import { parseMaybeNonEmptyString } from "./utils";
export interface JWTWithAccessToken extends JWT {
accessToken: string;
accessTokenExpires: number;
refreshToken: string;
refreshToken?: string;
error?: string;
}
@@ -65,3 +65,10 @@ export const assertExtendedTokenAndUserId = <U, T extends { user?: U }>(
}
throw new Error("Token is not extended with user id");
};
// best attempt to check the session is valid
export const assertCustomSession = <S extends Session>(s: S): CustomSession => {
const r = assertExtendedTokenAndUserId(s);
// no other checks for now
return r as CustomSession;
};