mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2025-12-20 20:29:06 +00:00
protect from zombie auth
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import NextAuth from "next-auth";
|
||||
import { authOptions } from "../../../lib/auth";
|
||||
import { authOptions } from "../../../lib/authBackend";
|
||||
|
||||
const handler = NextAuth(authOptions);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
179
www/app/lib/authBackend.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user