Highly ambitious ATProtocol AppView service and sdks

use deno.kv for session/state storage in /frontend, reorganize refactor oauth stuff

-1
frontend/.env.example
··· 2 2 OAUTH_CLIENT_SECRET="your-oauth-client-secret" 3 3 OAUTH_REDIRECT_URI="http://localhost:8080/oauth/callback" 4 4 OAUTH_AIP_BASE_URL="https://your-domain.com" 5 - SESSION_ENCRYPTION_KEY="your-base64-encoded-encryption-key" 6 5 API_URL="http://localhost:3000" 7 6 SLICE_URI="at://did:plc:bcgltzqazw5tb6k2g3ttenbj/social.slices.slice/3lwzmbjpqxk2q"
+1
frontend/.gitignore
··· 1 1 node_modules 2 2 .env 3 + *db*
+2 -2
frontend/deno.json
··· 1 1 { 2 2 "tasks": { 3 - "start": "deno run -A --env-file=.env src/main.ts", 4 - "dev": "deno run -A --env-file=.env --watch src/main.ts" 3 + "start": "deno run -A --env-file=.env --unstable-kv src/main.ts", 4 + "dev": "deno run -A --env-file=.env --unstable-kv --watch src/main.ts" 5 5 }, 6 6 "fmt": { 7 7 "useTabs": false,
+6 -1
frontend/flake.nix
··· 42 42 name = "image-root"; 43 43 paths = [ 44 44 pkgs.deno 45 + pkgs.sqlite 45 46 pkgs.cacert 46 47 ]; 47 48 pathsToLink = [ "/bin" "/etc" ]; ··· 51 52 #!${pkgs.runtimeShell} 52 53 mkdir -p /app 53 54 cp -r ${slice-frontend}/* /app/ 55 + # Create data directory for Deno KV with proper permissions 56 + mkdir -p /data 57 + chmod 755 /data 54 58 ''; 55 59 56 60 config = { 57 - Cmd = [ "/bin/deno" "run" "-A" "/app/src/main.ts" ]; 61 + Cmd = [ "/bin/deno" "run" "-A" "--unstable-kv" "/app/src/main.ts" ]; 58 62 ExposedPorts = { 59 63 "8080/tcp" = {}; 60 64 }; 61 65 WorkingDir = "/app"; 62 66 Env = [ 63 67 "PORT=8080" 68 + "KV_PATH=/data/kv.db" 64 69 ]; 65 70 }; 66 71 };
+4
frontend/fly.toml
··· 26 26 memory = '512mb' 27 27 cpu_kind = 'shared' 28 28 cpus = 1 29 + 30 + [mounts] 31 + source = 'frontend_data' 32 + destination = '/data'
+2 -275
frontend/src/config.ts
··· 6 6 const OAUTH_CLIENT_SECRET = Deno.env.get("OAUTH_CLIENT_SECRET"); 7 7 const OAUTH_REDIRECT_URI = Deno.env.get("OAUTH_REDIRECT_URI"); 8 8 const OAUTH_AIP_BASE_URL = Deno.env.get("OAUTH_AIP_BASE_URL"); 9 - const SESSION_ENCRYPTION_KEY = Deno.env.get("SESSION_ENCRYPTION_KEY"); 10 9 const API_URL = Deno.env.get("API_URL"); 11 10 const SLICE_URI = Deno.env.get("SLICE_URI"); 12 11 ··· 15 14 !OAUTH_CLIENT_SECRET || 16 15 !OAUTH_REDIRECT_URI || 17 16 !OAUTH_AIP_BASE_URL || 18 - !SESSION_ENCRYPTION_KEY || 19 17 !API_URL || 20 18 !SLICE_URI 21 19 ) { 22 20 throw new Error( 23 21 "Missing OAuth configuration. Please ensure .env file contains:\n" + 24 - "OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET, OAUTH_REDIRECT_URI, OAUTH_AIP_BASE_URL, SESSION_ENCRYPTION_KEY, API_URL, SLICE_URI" 22 + "OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET, OAUTH_REDIRECT_URI, OAUTH_AIP_BASE_URL, API_URL, SLICE_URI" 25 23 ); 26 24 } 27 25 ··· 37 35 export const oauthConfig = { 38 36 redirectUri: OAUTH_REDIRECT_URI, 39 37 scopes: ["atproto:atproto", "atproto:transition:generic"], 40 - }; 41 - 42 - // Simple in-memory storage for OAuth state 43 - export class OAuthStateManager { 44 - private static states = new Map< 45 - string, 46 - { codeVerifier: string; timestamp: number } 47 - >(); 48 - 49 - static store(state: string, codeVerifier: string): void { 50 - this.states.set(state, { 51 - codeVerifier, 52 - timestamp: Date.now(), 53 - }); 54 - 55 - // Auto-cleanup expired states (older than 10 minutes) 56 - this.cleanup(); 57 - } 58 - 59 - static retrieve(state: string): string | null { 60 - const stored = this.states.get(state); 61 - if (!stored) return null; 62 - 63 - this.states.delete(state); // Use once and delete 64 - return stored.codeVerifier; 65 - } 66 - 67 - private static cleanup(): void { 68 - const now = Date.now(); 69 - for (const [key, value] of this.states.entries()) { 70 - if (now - value.timestamp > 10 * 60 * 1000) { 71 - // 10 minutes 72 - this.states.delete(key); 73 - } 74 - } 75 - } 76 - } 77 - 78 - class SecureCookies { 79 - private static get key(): string { 80 - return SESSION_ENCRYPTION_KEY!; 81 - } 82 - 83 - // Encrypt data using AES-GCM 84 - static async encrypt(plaintext: string): Promise<string> { 85 - const encoder = new TextEncoder(); 86 - const data = encoder.encode(plaintext); 87 - 88 - // Generate a random IV 89 - const iv = crypto.getRandomValues(new Uint8Array(12)); 90 - 91 - // Import key for AES-GCM 92 - const keyData = encoder.encode(this.key.padEnd(32, "0").slice(0, 32)); // Ensure 32 bytes 93 - const cryptoKey = await crypto.subtle.importKey( 94 - "raw", 95 - keyData, 96 - { name: "AES-GCM" }, 97 - false, 98 - ["encrypt"] 99 - ); 100 - 101 - // Encrypt the data 102 - const encrypted = await crypto.subtle.encrypt( 103 - { name: "AES-GCM", iv: iv }, 104 - cryptoKey, 105 - data 106 - ); 107 - 108 - // Combine IV and encrypted data 109 - const combined = new Uint8Array(iv.length + encrypted.byteLength); 110 - combined.set(iv); 111 - combined.set(new Uint8Array(encrypted), iv.length); 112 - 113 - return btoa(String.fromCharCode(...combined)); 114 - } 115 - 116 - // Decrypt data using AES-GCM 117 - static async decrypt(encryptedData: string): Promise<string | null> { 118 - try { 119 - const encoder = new TextEncoder(); 120 - const decoder = new TextDecoder(); 121 - const combined = new Uint8Array( 122 - atob(encryptedData) 123 - .split("") 124 - .map((c) => c.charCodeAt(0)) 125 - ); 126 - 127 - // Extract IV and encrypted data 128 - const iv = combined.slice(0, 12); 129 - const encrypted = combined.slice(12); 130 - 131 - // Import key for AES-GCM 132 - const keyData = encoder.encode(this.key.padEnd(32, "0").slice(0, 32)); 133 - const cryptoKey = await crypto.subtle.importKey( 134 - "raw", 135 - keyData, 136 - { name: "AES-GCM" }, 137 - false, 138 - ["decrypt"] 139 - ); 140 - 141 - // Decrypt the data 142 - const decrypted = await crypto.subtle.decrypt( 143 - { name: "AES-GCM", iv: iv }, 144 - cryptoKey, 145 - encrypted 146 - ); 147 - 148 - return decoder.decode(decrypted); 149 - } catch (_e) { 150 - return null; 151 - } 152 - } 153 - 154 - // Create HMAC signature for cookie integrity 155 - static async sign(value: string): Promise<string> { 156 - const encoder = new TextEncoder(); 157 - const keyData = encoder.encode(this.key); 158 - const valueData = encoder.encode(value); 159 - 160 - const cryptoKey = await crypto.subtle.importKey( 161 - "raw", 162 - keyData, 163 - { name: "HMAC", hash: "SHA-256" }, 164 - false, 165 - ["sign"] 166 - ); 167 - 168 - const signature = await crypto.subtle.sign("HMAC", cryptoKey, valueData); 169 - const signatureBase64 = btoa( 170 - String.fromCharCode(...new Uint8Array(signature)) 171 - ); 172 - 173 - return `${value}.${signatureBase64}`; 174 - } 175 - 176 - // Verify HMAC signature and extract value 177 - static async verify(signedValue: string): Promise<string | null> { 178 - const parts = signedValue.split("."); 179 - if (parts.length !== 2) return null; 180 - 181 - const [value, _signature] = parts; 182 - const expectedSigned = await this.sign(value); 183 - 184 - // Constant-time comparison to prevent timing attacks 185 - if (expectedSigned === signedValue) { 186 - return value; 187 - } 188 - 189 - return null; 190 - } 191 - 192 - // Encrypt then sign for authenticated encryption 193 - static async encryptAndSign(plaintext: string): Promise<string> { 194 - const encrypted = await this.encrypt(plaintext); 195 - return await this.sign(encrypted); 196 - } 197 - 198 - // Verify signature then decrypt 199 - static async verifyAndDecrypt( 200 - signedEncrypted: string 201 - ): Promise<string | null> { 202 - const encrypted = await this.verify(signedEncrypted); 203 - if (!encrypted) return null; 204 - 205 - return await this.decrypt(encrypted); 206 - } 207 - } 208 - 209 - // Types for session data 210 - interface TokenStorage { 211 - accessToken?: string; 212 - refreshToken?: string; 213 - expiresAt?: number; 214 - tokenType?: string; 215 - } 216 - 217 - interface UserData { 218 - handle?: string; 219 - sub?: string; 220 - tokens?: TokenStorage; 221 - timestamp?: number; 222 - } 223 - 224 - // Cookie-based user session and token storage with HMAC signing 225 - export class UserSessionManager { 226 - static async refreshUserInfo(): Promise<UserData> { 227 - const userInfo = await atprotoClient.oauth?.getUserInfo(); 228 - 229 - // Get current token info from client 230 - const tokens = atprotoClient.getTokenStorage(); 231 - 232 - if (userInfo) { 233 - const userData = { 234 - handle: userInfo.did, // Use DID as handle for now 235 - sub: userInfo.sub, 236 - tokens: tokens, 237 - }; 238 - 239 - return userData; 240 - } 241 - 242 - return {}; 243 - } 244 - 245 - static async getCurrentUser( 246 - req: Request 247 - ): Promise<{ handle?: string; sub?: string; isAuthenticated: boolean }> { 248 - // Parse session data from signed cookie 249 - const cookies = req.headers.get("cookie") || ""; 250 - const sessionCookie = cookies 251 - .split("; ") 252 - .find((row) => row.startsWith("user_session=")); 253 - 254 - let userData: UserData = {}; 255 - if (sessionCookie) { 256 - try { 257 - const signedEncryptedValue = decodeURIComponent( 258 - sessionCookie.split("=")[1] 259 - ); 260 - const decryptedValue = await SecureCookies.verifyAndDecrypt( 261 - signedEncryptedValue 262 - ); 263 - 264 - if (decryptedValue) { 265 - userData = JSON.parse(decryptedValue); 266 - 267 - // Restore tokens to client if available and not expired 268 - if (userData.tokens) { 269 - const now = Date.now(); 270 - const isExpired = 271 - userData.tokens.expiresAt && now >= userData.tokens.expiresAt; 272 - 273 - if (!isExpired) { 274 - // Restore tokens to client using the public method 275 - atprotoClient.setTokensFromSession(userData.tokens); 276 - } 277 - } 278 - } 279 - } catch (_e) { 280 - // Silently ignore invalid cookies 281 - } 282 - } 283 - 284 - const authInfo = atprotoClient.oauth?.getAuthenticationInfo(); 285 - return { 286 - handle: userData.handle, 287 - sub: userData.sub, 288 - isAuthenticated: authInfo?.isAuthenticated || false, 289 - }; 290 - } 291 - 292 - static async createSessionCookie(userData: UserData): Promise<string> { 293 - const dataToStore = { 294 - handle: userData.handle, 295 - sub: userData.sub, 296 - tokens: userData.tokens, 297 - timestamp: Date.now(), // Add timestamp for freshness checks 298 - }; 299 - 300 - const jsonValue = JSON.stringify(dataToStore); 301 - const encryptedSignedValue = await SecureCookies.encryptAndSign(jsonValue); 302 - const cookieValue = encodeURIComponent(encryptedSignedValue); 303 - 304 - // Production cookie settings - 30 days expiration 305 - return `user_session=${cookieValue}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=2592000`; // 30 days 306 - } 307 - 308 - static createClearCookie(): string { 309 - return `user_session=; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=0`; 310 - } 311 - } 38 + };
+18
frontend/src/lib/kv.ts
··· 1 + // Shared Deno KV instance 2 + let kvInstance: Deno.Kv | null = null; 3 + 4 + export async function getKv(): Promise<Deno.Kv> { 5 + if (!kvInstance) { 6 + try { 7 + // Use persistent file-based KV store 8 + const kvPath = Deno.env.get("KV_PATH") || "./kv.db"; 9 + kvInstance = await Deno.openKv(kvPath); 10 + } catch (error) { 11 + const message = error instanceof Error ? error.message : String(error); 12 + throw new Error( 13 + `Failed to initialize Deno KV: ${message}. Make sure to run with --unstable-kv flag.` 14 + ); 15 + } 16 + } 17 + return kvInstance; 18 + }
+47
frontend/src/lib/oauth-state-store.ts
··· 1 + interface OAuthState { 2 + codeVerifier: string; 3 + timestamp: number; 4 + } 5 + 6 + // KV-based OAuth state storage for PKCE flow 7 + export class OAuthStateStore { 8 + constructor(private kv: Deno.Kv) {} 9 + 10 + async store(state: string, codeVerifier: string): Promise<void> { 11 + const stateData: OAuthState = { 12 + codeVerifier, 13 + timestamp: Date.now(), 14 + }; 15 + 16 + // Store with 10 minute expiration 17 + await this.kv.set( 18 + ["oauth_states", state], 19 + stateData, 20 + { expireIn: 10 * 60 * 1000 } 21 + ); 22 + 23 + // Auto-cleanup expired states 24 + await this.cleanup(); 25 + } 26 + 27 + async retrieve(state: string): Promise<string | null> { 28 + const result = await this.kv.get<OAuthState>(["oauth_states", state]); 29 + 30 + if (!result.value) return null; 31 + 32 + // Delete after use (one-time use) 33 + await this.kv.delete(["oauth_states", state]); 34 + 35 + return result.value.codeVerifier; 36 + } 37 + 38 + private async cleanup(): Promise<void> { 39 + const cutoff = Date.now() - (10 * 60 * 1000); // 10 minutes ago 40 + 41 + for await (const entry of this.kv.list<OAuthState>({ prefix: ["oauth_states"] })) { 42 + if (entry.value.timestamp < cutoff) { 43 + await this.kv.delete(entry.key); 44 + } 45 + } 46 + } 47 + }
+192
frontend/src/lib/session-store.ts
··· 1 + // Deno KV-based session storage for user authentication 2 + import { atprotoClient } from "../config.ts"; 3 + 4 + export interface TokenStorage { 5 + accessToken?: string; 6 + refreshToken?: string; 7 + expiresAt?: number; 8 + tokenType?: string; 9 + scope?: string; 10 + } 11 + 12 + export interface SessionData { 13 + userDid: string; 14 + handle?: string; 15 + tokens: TokenStorage; 16 + createdAt: number; 17 + } 18 + 19 + interface UserData { 20 + handle?: string; 21 + sub?: string; 22 + tokens?: TokenStorage; 23 + timestamp?: number; 24 + } 25 + 26 + export class SessionStore { 27 + constructor(private kv: Deno.Kv) {} 28 + 29 + // Create a new session 30 + async createSession(sessionData: SessionData): Promise<string> { 31 + const sessionId = crypto.randomUUID(); 32 + const expirationMs = 30 * 24 * 60 * 60 * 1000; // 30 days 33 + 34 + await this.kv.set( 35 + ["sessions", sessionId], 36 + { 37 + ...sessionData, 38 + createdAt: Date.now(), 39 + }, 40 + { expireIn: expirationMs } 41 + ); 42 + 43 + return sessionId; 44 + } 45 + 46 + // Get session data 47 + async getSession(sessionId: string): Promise<SessionData | null> { 48 + const result = await this.kv.get<SessionData>(["sessions", sessionId]); 49 + return result.value; 50 + } 51 + 52 + // Update session data 53 + async updateSession( 54 + sessionId: string, 55 + sessionData: Partial<SessionData> 56 + ): Promise<boolean> { 57 + const existing = await this.kv.get<SessionData>(["sessions", sessionId]); 58 + 59 + if (!existing.value) { 60 + return false; 61 + } 62 + 63 + const updated = { 64 + ...existing.value, 65 + ...sessionData, 66 + }; 67 + 68 + const expirationMs = 30 * 24 * 60 * 60 * 1000; // 30 days 69 + await this.kv.set(["sessions", sessionId], updated, { 70 + expireIn: expirationMs, 71 + }); 72 + 73 + return true; 74 + } 75 + 76 + // Update tokens for a session 77 + updateTokens(sessionId: string, tokens: TokenStorage): Promise<boolean> { 78 + return this.updateSession(sessionId, { tokens }); 79 + } 80 + 81 + // Delete a session (logout) 82 + async deleteSession(sessionId: string): Promise<void> { 83 + await this.kv.delete(["sessions", sessionId]); 84 + } 85 + 86 + // Refresh user info from OAuth client 87 + async refreshUserInfo(): Promise<UserData> { 88 + const userInfo = await atprotoClient.oauth?.getUserInfo(); 89 + const tokens = atprotoClient.getTokenStorage(); 90 + 91 + if (userInfo) { 92 + return { 93 + handle: userInfo.did, 94 + sub: userInfo.sub, 95 + tokens: tokens, 96 + }; 97 + } 98 + return {}; 99 + } 100 + 101 + // Get current user from request 102 + async getCurrentUser( 103 + req: Request 104 + ): Promise<{ handle?: string; sub?: string; isAuthenticated: boolean }> { 105 + const sessionId = getSessionIdFromRequest(req); 106 + 107 + let sessionData: SessionData | null = null; 108 + if (sessionId) { 109 + try { 110 + sessionData = await this.getSession(sessionId); 111 + 112 + // Restore tokens to client if available and not expired 113 + if (sessionData && sessionData.tokens) { 114 + const now = Date.now(); 115 + const isExpired = 116 + sessionData.tokens.expiresAt && now >= sessionData.tokens.expiresAt; 117 + 118 + if (!isExpired) { 119 + atprotoClient.setTokensFromSession(sessionData.tokens); 120 + } 121 + } 122 + } catch (_e) { 123 + // Silently ignore session errors 124 + } 125 + } 126 + 127 + const authInfo = atprotoClient.oauth?.getAuthenticationInfo(); 128 + return { 129 + handle: sessionData?.handle, 130 + sub: sessionData?.userDid, 131 + isAuthenticated: authInfo?.isAuthenticated || false, 132 + }; 133 + } 134 + 135 + // Create session from user data 136 + async createSessionFromUserData(userData: UserData): Promise<string> { 137 + if (!userData.sub) { 138 + throw new Error("User DID (sub) is required for session creation"); 139 + } 140 + 141 + const sessionData: SessionData = { 142 + userDid: userData.sub, 143 + handle: userData.handle, 144 + tokens: userData.tokens || {}, 145 + createdAt: Date.now(), 146 + }; 147 + 148 + return await this.createSession(sessionData); 149 + } 150 + 151 + // Delete session from request 152 + async deleteSessionFromRequest(req: Request): Promise<void> { 153 + const sessionId = getSessionIdFromRequest(req); 154 + if (sessionId) { 155 + await this.deleteSession(sessionId); 156 + } 157 + } 158 + 159 + // Update tokens from request 160 + async updateTokensFromRequest(req: Request, tokens: TokenStorage): Promise<void> { 161 + const sessionId = getSessionIdFromRequest(req); 162 + if (sessionId) { 163 + await this.updateTokens(sessionId, tokens); 164 + } 165 + } 166 + } 167 + 168 + 169 + // Utility function to extract session ID from request 170 + export function getSessionIdFromRequest(request: Request): string | null { 171 + const cookies = request.headers.get("cookie") || ""; 172 + const sessionCookie = cookies 173 + .split("; ") 174 + .find((row) => row.startsWith("session_id=")); 175 + 176 + if (!sessionCookie) { 177 + return null; 178 + } 179 + 180 + return sessionCookie.split("=")[1]; 181 + } 182 + 183 + // Utility function to create session cookie 184 + export function createSessionCookie(sessionId: string): string { 185 + // HttpOnly, Secure, SameSite=Strict cookie with 30 day expiration 186 + return `session_id=${sessionId}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=2592000`; // 30 days 187 + } 188 + 189 + // Utility function to clear session cookie 190 + export function clearSessionCookie(): string { 191 + return `session_id=; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=0`; 192 + }
+9
frontend/src/lib/stores.ts
··· 1 + // Initialize all stores with shared KV instance 2 + import { getKv } from "./kv.ts"; 3 + import { SessionStore } from "./session-store.ts"; 4 + import { OAuthStateStore } from "./oauth-state-store.ts"; 5 + 6 + const kv = await getKv(); 7 + 8 + export const sessionStore = new SessionStore(kv); 9 + export const oauthStateStore = new OAuthStateStore(kv);
+8 -12
frontend/src/routes/middleware.ts
··· 1 - import { UserSessionManager, atprotoClient } from "../config.ts"; 1 + import { atprotoClient } from "../config.ts"; 2 + import { sessionStore } from "../lib/stores.ts"; 2 3 3 4 export interface AuthenticatedUser { 4 5 handle?: string; ··· 13 14 14 15 export async function withAuth(req: Request): Promise<RouteContext> { 15 16 // Get current user info (this already restores tokens from session) 16 - const currentUser = await UserSessionManager.getCurrentUser(req); 17 + const currentUser = await sessionStore.getCurrentUser(req); 17 18 18 - // Check if we need to refresh the session cookie with updated tokens 19 + // Check if we need to update the session with refreshed tokens 19 20 let sessionCookieHeader: string | undefined; 20 21 if (currentUser.isAuthenticated) { 21 22 const tokens = atprotoClient.getTokenStorage(); 22 23 if (tokens && tokens.accessToken) { 23 - // Refresh the session cookie to extend expiration and update any refreshed tokens 24 - const userData = { 25 - handle: currentUser.handle, 26 - sub: currentUser.sub, 27 - tokens: tokens, 28 - }; 29 - sessionCookieHeader = await UserSessionManager.createSessionCookie( 30 - userData 31 - ); 24 + // Update the session with any refreshed tokens 25 + await sessionStore.updateTokensFromRequest(req, tokens); 26 + 27 + // No need to set cookie header since session ID remains the same 32 28 } 33 29 } 34 30
+31 -12
frontend/src/routes/oauth.ts
··· 2 2 import { 3 3 atprotoClient, 4 4 oauthConfig, 5 - OAuthStateManager, 6 - UserSessionManager, 7 5 } from "../config.ts"; 6 + import { sessionStore, oauthStateStore } from "../lib/stores.ts"; 7 + import { createSessionCookie, clearSessionCookie } from "../lib/session-store.ts"; 8 8 9 9 async function handleOAuthAuthorize(req: Request): Promise<Response> { 10 10 try { 11 11 // Clear any existing auth state before new login attempt 12 - atprotoClient.oauth.logout(); 12 + atprotoClient.oauth?.logout(); 13 13 14 14 const formData = await req.formData(); 15 15 const loginHint = formData.get("loginHint") as string; ··· 18 18 return new Response("Missing login hint", { status: 400 }); 19 19 } 20 20 21 + if (!atprotoClient.oauth) { 22 + return new Response("OAuth client not configured", { status: 500 }); 23 + } 24 + 21 25 const authResult = await atprotoClient.oauth.authorize({ 22 26 loginHint, 23 27 redirectUri: oauthConfig.redirectUri, ··· 25 29 }); 26 30 27 31 // Store OAuth state for later verification 28 - OAuthStateManager.store(authResult.state, authResult.codeVerifier); 32 + await oauthStateStore.store(authResult.state, authResult.codeVerifier); 29 33 30 34 // Redirect to authorization URL 31 35 return Response.redirect(authResult.authorizationUrl, 302); ··· 58 62 } 59 63 60 64 // Retrieve stored code verifier 61 - const codeVerifier = OAuthStateManager.retrieve(state); 65 + const codeVerifier = await oauthStateStore.retrieve(state); 62 66 if (!codeVerifier) { 63 67 return Response.redirect( 64 68 new URL( ··· 70 74 ); 71 75 } 72 76 77 + if (!atprotoClient.oauth) { 78 + return Response.redirect( 79 + new URL( 80 + "/login?error=" + encodeURIComponent("OAuth client not configured"), 81 + req.url 82 + ), 83 + 302 84 + ); 85 + } 86 + 73 87 // Exchange code for tokens 74 88 await atprotoClient.oauth.handleCallback({ 75 89 code, ··· 79 93 }); 80 94 81 95 // Fetch and store user info 82 - const userData = await UserSessionManager.refreshUserInfo(); 96 + const userData = await sessionStore.refreshUserInfo(); 83 97 84 - // Redirect to main app on successful login with session cookie 85 - const sessionCookie = await UserSessionManager.createSessionCookie( 86 - userData 87 - ); 98 + // Create new session and get session cookie 99 + const sessionId = await sessionStore.createSessionFromUserData(userData); 100 + const sessionCookie = createSessionCookie(sessionId); 101 + 88 102 return new Response(null, { 89 103 status: 302, 90 104 headers: { ··· 105 119 } 106 120 107 121 async function handleLogout(req: Request): Promise<Response> { 108 - atprotoClient.oauth.logout(); 122 + // Delete the session from KV store 123 + await sessionStore.deleteSessionFromRequest(req); 124 + 125 + // Logout from OAuth client 126 + atprotoClient.oauth?.logout(); 127 + 109 128 return new Response(null, { 110 129 status: 302, 111 130 headers: { 112 131 Location: new URL("/login", req.url).toString(), 113 - "Set-Cookie": UserSessionManager.createClearCookie(), 132 + "Set-Cookie": clearSessionCookie(), 114 133 }, 115 134 }); 116 135 }
+3 -1
frontend/src/routes/pages.tsx
··· 1 1 import type { Route } from "@std/http/unstable-route"; 2 2 import { render } from "preact-render-to-string"; 3 - import { withAuth } from "./middleware.ts"; 3 + import { withAuth, requireAuth } from "./middleware.ts"; 4 4 import { atprotoClient } from "../config.ts"; 5 5 import { buildAtUri } from "../utils/at-uri.ts"; 6 6 import { IndexPage } from "../pages/IndexPage.tsx"; ··· 15 15 16 16 async function handleIndexPage(req: Request): Promise<Response> { 17 17 const context = await withAuth(req); 18 + const authResponse = requireAuth(context, req); 19 + if (authResponse) return authResponse; 18 20 19 21 // Slice list page - get real slices from AT Protocol 20 22 let slices: Array<{ id: string; name: string; createdAt: string }> = [];
+26 -26
frontend/src/routes/slices.tsx
··· 19 19 if (authResponse) return authResponse; 20 20 21 21 // Ensure client has tokens before attempting API calls 22 - const authInfo = atprotoClient.oauth.getAuthenticationInfo(); 23 - if (!authInfo.isAuthenticated) { 22 + const authInfo = atprotoClient.oauth?.getAuthenticationInfo(); 23 + if (!authInfo?.isAuthenticated) { 24 24 const dialogHtml = render( 25 25 <CreateSliceDialog error="Session expired. Please log in again." /> 26 26 ); ··· 61 61 "HX-Redirect": `/slices/${sliceId}`, 62 62 }, 63 63 }); 64 - } catch (createError) { 64 + } catch (_createError) { 65 65 const dialogHtml = render( 66 66 <CreateSliceDialog 67 67 error="Failed to create slice record. Please try again." ··· 73 73 headers: { "content-type": "text/html" }, 74 74 }); 75 75 } 76 - } catch (error) { 76 + } catch (_error) { 77 77 const dialogHtml = render( 78 78 <CreateSliceDialog error="Failed to create slice" /> 79 79 ); ··· 141 141 status: 200, 142 142 headers: { "content-type": "text/html" }, 143 143 }); 144 - } catch (error) { 144 + } catch (_error) { 145 145 const resultHtml = render( 146 146 <UpdateResult 147 147 type="error" ··· 179 179 "HX-Redirect": "/", 180 180 }, 181 181 }); 182 - } catch (error) { 182 + } catch (_error) { 183 183 return new Response("Failed to delete slice", { status: 500 }); 184 184 } 185 185 } ··· 530 530 headers: { "content-type": "text/html" }, 531 531 }); 532 532 } 533 - } catch (error) { 533 + } catch (_error) { 534 534 const html = render( 535 535 <SettingsResult type="error" message="Failed to process form data" /> 536 536 ); ··· 551 551 552 552 const sliceId = params?.pathname.groups.id; 553 553 if (!sliceId) { 554 - const html = render(<SyncResult success={false} error="Invalid slice ID" />); 554 + const html = render( 555 + <SyncResult success={false} error="Invalid slice ID" /> 556 + ); 555 557 return new Response(html, { 556 558 status: 400, 557 559 headers: { "content-type": "text/html" }, ··· 566 568 // Parse collections from textarea (newline or comma separated) 567 569 const collections: string[] = []; 568 570 if (collectionsText) { 569 - collectionsText 570 - .split(/[\n,]/) 571 - .forEach(item => { 572 - const trimmed = item.trim(); 573 - if (trimmed) collections.push(trimmed); 574 - }); 571 + collectionsText.split(/[\n,]/).forEach((item) => { 572 + const trimmed = item.trim(); 573 + if (trimmed) collections.push(trimmed); 574 + }); 575 575 } 576 576 577 577 if (collections.length === 0) { 578 578 const html = render( 579 - <SyncResult 580 - success={false} 581 - error="Please specify at least one collection to sync" 579 + <SyncResult 580 + success={false} 581 + error="Please specify at least one collection to sync" 582 582 /> 583 583 ); 584 584 return new Response(html, { ··· 590 590 // Parse repos if provided 591 591 const repos: string[] = []; 592 592 if (reposText) { 593 - reposText 594 - .split(/[\n,]/) 595 - .forEach(item => { 596 - const trimmed = item.trim(); 597 - if (trimmed) repos.push(trimmed); 598 - }); 593 + reposText.split(/[\n,]/).forEach((item) => { 594 + const trimmed = item.trim(); 595 + if (trimmed) repos.push(trimmed); 596 + }); 599 597 } 600 598 601 599 // Call the generated client's sync method ··· 621 619 }); 622 620 } catch (error) { 623 621 const html = render( 624 - <SyncResult 625 - success={false} 626 - error={`Error: ${error instanceof Error ? error.message : String(error)}`} 622 + <SyncResult 623 + success={false} 624 + error={`Error: ${ 625 + error instanceof Error ? error.message : String(error) 626 + }`} 627 627 /> 628 628 ); 629 629 return new Response(html, {