diff --git a/www/app/(app)/transcripts/new/page.tsx b/www/app/(app)/transcripts/new/page.tsx
index e6957da2..5dff4ef2 100644
--- a/www/app/(app)/transcripts/new/page.tsx
+++ b/www/app/(app)/transcripts/new/page.tsx
@@ -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 = () => {
{isLoading ? (
- ) : requireLogin && !isAuthenticated ? (
+ ) : requireLogin && !isAuthenticated && !isAuthRefreshing ? (
) : (
(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,
diff --git a/www/app/lib/SessionAutoRefresh.tsx b/www/app/lib/SessionAutoRefresh.tsx
index 1b7b05f0..9542581e 100644
--- a/www/app/lib/SessionAutoRefresh.tsx
+++ b/www/app/lib/SessionAutoRefresh.tsx
@@ -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);
diff --git a/www/app/lib/apiClient.tsx b/www/app/lib/apiClient.tsx
index 6c798ced..9c278cef 100644
--- a/www/app/lib/apiClient.tsx
+++ b/www/app/lib/apiClient.tsx
@@ -19,9 +19,6 @@ export const client = createClient({
export const $api = createFetchClient(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;
diff --git a/www/app/lib/apiHooks.ts b/www/app/lib/apiHooks.ts
index f14bc24c..7b82b65b 100644
--- a/www/app/lib/apiHooks.ts
+++ b/www/app/lib/apiHooks.ts
@@ -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
},
);
}
diff --git a/www/app/lib/auth.ts b/www/app/lib/auth.ts
index 1d2cc953..c5d39e75 100644
--- a/www/app/lib/auth.ts
+++ b/www/app/lib/auth.ts
@@ -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>();
-
-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 {
- 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 {
- 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;
diff --git a/www/app/lib/authBackend.ts b/www/app/lib/authBackend.ts
new file mode 100644
index 00000000..10cf3d40
--- /dev/null
+++ b/www/app/lib/authBackend.ts
@@ -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>();
+
+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 {
+ 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 {
+ 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;
+ }
+}
diff --git a/www/app/lib/types.ts b/www/app/lib/types.ts
index 39918f63..0576e186 100644
--- a/www/app/lib/types.ts
+++ b/www/app/lib/types.ts
@@ -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 = (
}
throw new Error("Token is not extended with user id");
};
+
+// best attempt to check the session is valid
+export const assertCustomSession = (s: S): CustomSession => {
+ const r = assertExtendedTokenAndUserId(s);
+ // no other checks for now
+ return r as CustomSession;
+};