The Appview for the kipclip.com atproto bookmarking service
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}