session-context.tsx (13163B)
1 "use client"; 2 3 import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react"; 4 import { generateOpenIDToken, login as realLogin, logout as realLogout } from "@/lib/clientSideAuth"; 5 import { User } from "@/lib/auth"; 6 7 type SessionContextType = { 8 user?: User; 9 isLoading: boolean; 10 isAuthenticated: boolean; 11 discoveryStatus: "idle" | "loading" | "success" | "error"; 12 homeserverUrl?: string; 13 login: (matrixId: string, password: string) => Promise<void>; 14 register: (matrixId: string, password: string) => Promise<void>; 15 logout: () => Promise<void>; 16 discoverHomeserver: (matrixId: string) => Promise<void>; 17 fetchWithTokenRefresh: (url: string, options?: RequestInit) => Promise<Response>; 18 refreshOpenIDToken: () => Promise<void>; 19 }; 20 21 const SessionContext = createContext<SessionContextType | undefined>(undefined); 22 23 const refreshOpenIDToken = async (homeserverUrl: string, accessToken: string, user: User, setUser: (user: User) => void, setOpenIDExpiration: (expiration: number) => void, logout: () => Promise<void>) => { 24 try { 25 const openidToken = await generateOpenIDToken(homeserverUrl, user.matrixId, accessToken); 26 27 // Update the session cookie with the new OpenID token 28 const response = await fetch("/api/session", { 29 method: "POST", 30 headers: { "Content-Type": "application/json" }, 31 body: JSON.stringify({ openidToken, homeserverUrl, matrixId: user.matrixId, openidExpiration: Math.floor(Date.now() / 1000) + openidToken.expires_in }), 32 }); 33 setOpenIDExpiration(openidToken.expires_in); 34 35 if (!response.ok) { 36 console.error("Failed to update session with new OpenID token:", response.status, response.statusText, response.body); 37 throw new Error("Failed to update session with new OpenID token.", { 38 cause: response.body 39 }); 40 } 41 42 const userData = await response.json(); 43 setUser(userData); 44 } catch (error) { 45 console.error("Failed to refresh OpenID token:", error); 46 await logout(); 47 48 throw error; 49 } 50 }; 51 52 export function SessionProvider({ children }: { children: ReactNode }) { 53 const [user, setUser] = useState<User | undefined>(); 54 const [isLoading, setIsLoading] = useState(false); 55 const [homeserverUrl, setHomeserverUrl] = useState<string | undefined>(); 56 const [discoveryStatus, setDiscoveryStatus] = useState<"idle" | "loading" | "success" | "error">("idle"); 57 const [accessToken, setAccessToken] = useState<string | undefined>(); 58 const [openidExpiration, setOpenIDExpiration] = useState<number | undefined>() 59 60 const logout = useCallback(async () => { 61 try { 62 await fetch("/api/session", { method: "DELETE" }); 63 64 if (homeserverUrl && accessToken) { 65 await realLogout(homeserverUrl, accessToken); 66 } 67 68 setUser(undefined); 69 setAccessToken(undefined); 70 setOpenIDExpiration(undefined); 71 72 // Clear sessionStorage 73 sessionStorage.removeItem("accessToken"); 74 sessionStorage.removeItem("openidExpiration"); 75 } catch (error) { 76 console.error("Logout failed:", error); 77 } 78 }, [accessToken, homeserverUrl]); 79 80 // Restore session from cookies on initial load 81 useEffect(() => { 82 if (homeserverUrl && accessToken && user) { 83 return; 84 } 85 const fetchSession = async () => { 86 console.log("Restoring session..."); 87 try { 88 if (openidExpiration) { 89 const now = Date.now(); 90 const refreshTime = openidExpiration - 60 * 1000; // Refresh 1 minute before expiration 91 92 if (refreshTime > now) { 93 const timeout = setTimeout(() => { 94 console.log("Refreshing OpenID token..."); 95 if (!homeserverUrl || !accessToken || !user) { 96 throw new Error("Missing required information to refresh OpenID token."); 97 } 98 refreshOpenIDToken(homeserverUrl, accessToken, user, setUser, setOpenIDExpiration, logout).catch((err) => { 99 console.error("Failed to refresh OpenID token:", err); 100 }); 101 }, refreshTime - now); 102 103 return () => clearTimeout(timeout); 104 } 105 } 106 const response = await fetch("/api/session"); 107 if (response.ok) { 108 const userData: User = await response.json(); 109 setUser(userData); 110 setAccessToken(userData.token); 111 setHomeserverUrl(userData.homeserverUrl); 112 } 113 } catch (error) { 114 console.error("Failed to restore session:", error); 115 } finally { 116 setIsLoading(false); 117 } 118 }; 119 120 fetchSession(); 121 }, [accessToken, homeserverUrl, logout, openidExpiration, user]); 122 123 124 125 // Automatically refresh OpenID token before it expires 126 useEffect(() => { 127 if (openidExpiration) { 128 const now = Date.now(); 129 const refreshTime = openidExpiration - 60 * 1000; // Refresh 1 minute before expiration 130 131 if (refreshTime > now) { 132 const timeout = setTimeout(() => { 133 if (!homeserverUrl || !accessToken || !user) { 134 throw new Error("Missing required information to refresh OpenID token."); 135 } 136 refreshOpenIDToken(homeserverUrl, accessToken, user, setUser, setOpenIDExpiration, logout).catch((err) => { 137 console.error("Failed to refresh OpenID token:", err); 138 }); 139 }, refreshTime - now); 140 141 return () => clearTimeout(timeout); 142 } 143 } 144 }, [accessToken, homeserverUrl, logout, openidExpiration, user]); 145 146 const discoverHomeserver = async (matrixId: string) => { 147 setDiscoveryStatus("loading"); 148 if (!matrixId.includes(":")) { 149 setDiscoveryStatus("error"); 150 throw new Error("Invalid Matrix ID format. Please include a domain (e.g., @user:domain)."); 151 } 152 153 const serverName = matrixId.split(":").pop(); 154 155 try { 156 const url = new URL("/.well-known/matrix/client", `https://${serverName}`); 157 const response = await fetch(url); 158 if (response.ok) { 159 // Check if the response is empty 160 if (!response.body) { 161 setHomeserverUrl(`https://${serverName}`); 162 setDiscoveryStatus("success"); 163 return; 164 } 165 166 const data = await response.json(); 167 if (data["m.homeserver"]?.base_url) { 168 // Check if the base_url is a valid URL by checking /_matrix/client/versions on the endpoint 169 const url = new URL("/_matrix/client/versions", data["m.homeserver"].base_url) 170 const versionsResponse = await fetch(url); 171 console.log("Versions response:", versionsResponse.status); 172 if (versionsResponse.ok) { 173 setHomeserverUrl(data["m.homeserver"].base_url); 174 setDiscoveryStatus("success"); 175 return; 176 } else { 177 setDiscoveryStatus("error"); 178 throw new Error("Homeserver did not respond successfully to versions request."); 179 } 180 } 181 } 182 setHomeserverUrl(`https://${serverName}`); 183 setDiscoveryStatus("success"); 184 } catch (error: unknown) { 185 console.warn("Failed to discover homeserver:", error); 186 setHomeserverUrl(`https://${serverName}`); 187 setDiscoveryStatus("success"); 188 } 189 }; 190 191 const login = async (matrixId: string, password: string) => { 192 setIsLoading(true); 193 if (!homeserverUrl) { 194 throw new Error("Homeserver URL is not set. Please discover your homeserver first."); 195 } 196 197 try { 198 // Step 1: Login to Matrix and get access token 199 const accessToken = await realLogin(homeserverUrl, matrixId, password); 200 setAccessToken(accessToken); 201 202 // Step 2: Generate OpenID token 203 const { access_token: openidToken, expires_in } = await generateOpenIDToken(homeserverUrl, matrixId, accessToken); 204 setOpenIDExpiration(expires_in); 205 206 // Step 3: Send OpenID token to the server for validation and session creation 207 const response = await fetch("/api/session", { 208 method: "POST", 209 headers: { "Content-Type": "application/json" }, 210 body: JSON.stringify({ openidToken, homeserverUrl, matrixId, openidExpiration: Math.floor(Date.now() / 1000) + expires_in }), 211 }); 212 213 if (!response.ok) { 214 throw new Error("Failed to create session on the server."); 215 } 216 217 const userData = await response.json(); 218 setUser(userData); 219 220 // Persist access token and expiration in sessionStorage 221 sessionStorage.setItem("accessToken", accessToken); 222 sessionStorage.setItem("openidExpiration", expires_in.toString()); 223 } catch (error) { 224 console.error("Login failed:", error); 225 throw error; 226 } finally { 227 setIsLoading(false); 228 } 229 }; 230 231 const register = async (matrixId: string, password: string) => { 232 setIsLoading(true); 233 if (!homeserverUrl) { 234 throw new Error("Homeserver URL is not set. Please discover your homeserver first."); 235 } 236 237 try { 238 239 // Step 1: Login to Matrix and get access token 240 const accessToken = await realLogin(homeserverUrl, matrixId, password); 241 setAccessToken(accessToken); 242 243 // Step 2: Generate OpenID token 244 const { access_token: openidToken, expires_in } = await generateOpenIDToken(homeserverUrl, matrixId, accessToken); 245 setOpenIDExpiration(expires_in); 246 247 // Step 3: Send OpenID token to the server for validation and session creation 248 const response = await fetch("/api/session", { 249 method: "POST", 250 headers: { "Content-Type": "application/json" }, 251 body: JSON.stringify({ openidToken, homeserverUrl, matrixId, openidExpiration: Math.floor(Date.now() / 1000) + expires_in }), 252 }); 253 254 if (!response.ok) { 255 throw new Error("Failed to create session on the server."); 256 } 257 const userData = await response.json(); 258 setUser(userData); 259 260 // Persist access token and expiration in sessionStorage 261 sessionStorage.setItem("accessToken", accessToken); 262 sessionStorage.setItem("openidExpiration", expires_in.toString()); 263 } catch (error) { 264 console.error("Login failed:", error); 265 throw error; 266 } finally { 267 setIsLoading(false); 268 } 269 }; 270 271 const refreshOpenIDTokenHook = async () => { 272 if (!homeserverUrl || !accessToken || !user) { 273 throw new Error("Missing required information to refresh OpenID token."); 274 } 275 console.log("Refreshing OpenID token..."); 276 277 await refreshOpenIDToken(homeserverUrl, accessToken, user, setUser, setOpenIDExpiration, logout); 278 } 279 280 const fetchWithTokenRefresh = async (url: string, options: RequestInit = {}) => { 281 const response = await fetch(url, options); 282 283 if (response.status === 401) { 284 // Token expired, refresh it 285 if (!homeserverUrl || !accessToken || !user) { 286 throw new Error("Missing required information to refresh OpenID token."); 287 } 288 await refreshOpenIDToken(homeserverUrl, accessToken, user, setUser, setOpenIDExpiration, logout); 289 290 // Retry the request 291 return fetch(url, options); 292 } 293 294 return response; 295 }; 296 297 return ( 298 <SessionContext.Provider 299 value={{ 300 user, 301 isLoading, 302 homeserverUrl, 303 discoveryStatus, 304 isAuthenticated: !!user, 305 login, 306 register, 307 logout, 308 discoverHomeserver, 309 fetchWithTokenRefresh, 310 refreshOpenIDToken: refreshOpenIDTokenHook, 311 }} 312 > 313 {children} 314 </SessionContext.Provider> 315 ); 316 } 317 318 export function useSession() { 319 const context = useContext(SessionContext); 320 if (context === undefined) { 321 throw new Error("useSession must be used within a SessionProvider"); 322 } 323 return context; 324 }