The Appview for the kipclip.com atproto bookmarking service
at main 227 lines 6.4 kB view raw
1/** 2 * Session utilities with comprehensive error logging. 3 * Uses @tijs/atproto-oauth for OAuth and session management. 4 */ 5 6import type { SessionInterface } from "@tijs/atproto-oauth"; 7import { SessionManager } from "@tijs/atproto-sessions"; 8import { captureError } from "./sentry.ts"; 9import { getOAuth } from "./oauth-config.ts"; 10 11// Test session provider override (set via setTestSessionProvider) 12let testSessionProvider: 13 | ((request: Request) => Promise<SessionResult>) 14 | null = null; 15 16/** 17 * Set a test session provider for testing authenticated routes. 18 * Call with null to restore default behavior. 19 * @internal Only for use in tests 20 */ 21export function setTestSessionProvider( 22 provider: ((request: Request) => Promise<SessionResult>) | null, 23): void { 24 testSessionProvider = provider; 25} 26 27// Session configuration from environment (lazy-loaded) 28let sessions: SessionManager | null = null; 29 30function getSessionManager(): SessionManager { 31 if (!sessions) { 32 const COOKIE_SECRET = Deno.env.get("COOKIE_SECRET"); 33 if (!COOKIE_SECRET) { 34 throw new Error("COOKIE_SECRET environment variable is required"); 35 } 36 37 // Create session manager for cookie handling (framework-agnostic) 38 sessions = new SessionManager({ 39 cookieSecret: COOKIE_SECRET, 40 cookieName: "sid", 41 sessionTtl: 60 * 60 * 24 * 14, // 14 days 42 logger: console, 43 }); 44 } 45 return sessions; 46} 47 48export interface SessionResult { 49 session: SessionInterface | null; 50 /** Set-Cookie header to refresh the session - should be set on response */ 51 setCookieHeader?: string; 52 error?: { 53 type: string; 54 message: string; 55 details?: unknown; 56 }; 57} 58 59/** 60 * Report session error to Sentry for monitoring. 61 */ 62function reportSessionError( 63 errorType: string, 64 errorMessage: string, 65 context: Record<string, unknown>, 66): void { 67 const error = new Error(`Session Error: ${errorType} - ${errorMessage}`); 68 error.name = errorType; 69 captureError(error, { 70 errorType, 71 errorMessage, 72 ...context, 73 }); 74} 75 76/** 77 * Get OAuth session from request with detailed error logging and cookie refresh. 78 * 79 * Uses @tijs/atproto-sessions for cookie extraction and refresh, 80 * then gets the OAuth session via hono-oauth-sessions. 81 * 82 * @param request - The HTTP request 83 * @returns SessionResult with session, setCookieHeader, and optional error 84 */ 85export async function getSessionFromRequest( 86 request: Request, 87): Promise<SessionResult> { 88 // Check for test session provider (testing only) 89 if (testSessionProvider) { 90 return testSessionProvider(request); 91 } 92 93 try { 94 // Step 1: Extract session data from cookie using atproto-sessions 95 const cookieResult = await getSessionManager().getSessionFromRequest( 96 request, 97 ); 98 99 if (!cookieResult.data) { 100 const errorType = cookieResult.error?.type || "NO_SESSION"; 101 const errorMessage = cookieResult.error?.message || 102 "No active session found"; 103 104 console.warn("[Session] No session cookie found", { 105 url: request.url, 106 hasCookie: request.headers.get("cookie")?.includes("sid="), 107 errorType, 108 errorMessage, 109 timestamp: new Date().toISOString(), 110 }); 111 112 return { 113 session: null, 114 error: { 115 type: errorType, 116 message: errorMessage, 117 details: cookieResult.error?.details, 118 }, 119 }; 120 } 121 122 // Step 2: Get OAuth session using the DID from cookie 123 // restore() can throw typed errors for expired/revoked/missing sessions 124 const did = cookieResult.data.did; 125 let oauthSession: SessionInterface | null; 126 try { 127 oauthSession = await getOAuth().sessions.getOAuthSession(did); 128 } catch (restoreError) { 129 // Known session errors — return null session, no Sentry report 130 const errorName = restoreError instanceof Error 131 ? restoreError.constructor.name 132 : ""; 133 const isRecoverableSessionError = [ 134 "SessionNotFoundError", 135 "SessionError", 136 "RefreshTokenExpiredError", 137 "RefreshTokenRevokedError", 138 "TokenExchangeError", 139 ].includes(errorName); 140 141 if (isRecoverableSessionError) { 142 console.warn("[Session] OAuth session restore failed (recoverable)", { 143 did, 144 errorName, 145 errorMessage: restoreError instanceof Error 146 ? restoreError.message 147 : String(restoreError), 148 url: request.url, 149 }); 150 return { 151 session: null, 152 setCookieHeader: cookieResult.setCookieHeader, 153 error: { 154 type: "SESSION_EXPIRED", 155 message: "Your session has expired, please sign in again", 156 }, 157 }; 158 } 159 160 // Unknown/transient errors (e.g. NetworkError) — re-throw to outer catch 161 throw restoreError; 162 } 163 164 if (!oauthSession) { 165 console.warn("[Session] OAuth session not available", { 166 did, 167 url: request.url, 168 }); 169 170 return { 171 session: null, 172 error: { 173 type: "SESSION_EXPIRED", 174 message: "Your session has expired, please sign in again", 175 }, 176 }; 177 } 178 179 // Session found successfully 180 console.debug("[Session] Valid session retrieved", { 181 did: oauthSession.did, 182 url: request.url, 183 hasRefreshCookie: !!cookieResult.setCookieHeader, 184 timestamp: new Date().toISOString(), 185 }); 186 187 return { 188 session: oauthSession, 189 setCookieHeader: cookieResult.setCookieHeader, 190 }; 191 } catch (error) { 192 // Unexpected error 193 const errorType = error instanceof Error 194 ? error.constructor.name 195 : "Unknown"; 196 const errorMessage = error instanceof Error ? error.message : String(error); 197 198 console.error("[Session] Unexpected error getting session", { 199 errorType, 200 errorMessage, 201 url: request.url, 202 timestamp: new Date().toISOString(), 203 stack: error instanceof Error ? error.stack : undefined, 204 }); 205 206 reportSessionError(errorType, errorMessage, { 207 url: request.url, 208 stack: error instanceof Error ? error.stack : undefined, 209 }); 210 211 return { 212 session: null, 213 error: { 214 type: errorType, 215 message: errorMessage, 216 details: error, 217 }, 218 }; 219 } 220} 221 222/** 223 * Get clear cookie header for session cleanup. 224 */ 225export function getClearSessionCookie(): string { 226 return getSessionManager().getClearCookieHeader(); 227}