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