-1
frontend/.env.example
-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"
+2
-2
frontend/deno.json
+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
+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
+4
frontend/fly.toml
+2
-275
frontend/src/config.ts
+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
+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
+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
+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
+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
+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
+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
+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
+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, {