+16
-1
dev.compose.yml
+16
-1
dev.compose.yml
···
1
1
services:
2
+
postgres:
3
+
image: postgres:18
4
+
restart: unless-stopped
5
+
environment:
6
+
POSTGRES_USER: ${DB_USER}
7
+
POSTGRES_PASSWORD: ${DB_PASSWORD}
8
+
POSTGRES_DB: ${DB_NAME}
9
+
ports:
10
+
- "5432:5432"
11
+
volumes:
12
+
- postgres_data:/var/lib/postgresql
13
+
extra_hosts:
14
+
- "host.docker.internal:host-gateway"
15
+
networks:
16
+
- services-network
2
17
valkey:
3
18
image: valkey/valkey:9.0
4
19
ports:
···
13
28
- services-network
14
29
volumes:
15
30
valkey_data:
16
-
app_data:
31
+
postgres_data:
17
32
networks:
18
33
services-network:
19
34
driver: bridge
+8
-3
src/lib/server/cache.ts
+8
-3
src/lib/server/cache.ts
···
62
62
return undefined;
63
63
}
64
64
65
-
async set(key: string, value: string) {
66
-
const expiryOptions = this.expire ?
65
+
async set(key: string, value: string, customExpire?: number) {
66
+
const expireSeconds = customExpire ?? this.expire;
67
+
const expiryOptions = expireSeconds ?
67
68
{
68
69
expiry: {
69
70
type: TimeUnit.Seconds,
70
-
count: this.expire as number
71
+
count: expireSeconds
71
72
}
72
73
} : undefined;
73
74
return await this.valKeyClient.set(this.$key(key), value, expiryOptions);
···
75
76
76
77
async delete(key: string) {
77
78
await this.valKeyClient.del([this.$key(key)]);
79
+
}
80
+
81
+
async refreshExpiry(key: string, seconds: number) {
82
+
await this.valKeyClient.expire(this.$key(key), seconds);
78
83
}
79
84
80
85
}
+42
-40
src/lib/server/session.ts
+42
-40
src/lib/server/session.ts
···
1
1
// A cookie session store based on https://lucia-auth.com/ examples which is recommended from Svelte's docs
2
-
// Creates a cookie that links to a session store inside the database allowing the atproto oauth session to be loaded
2
+
// Creates a cookie that links to a session store inside Valkey allowing the atproto oauth session to be loaded
3
3
4
-
import { db } from './db';
5
4
import { atpOAuthClient } from './atproto/client';
6
5
import { encodeBase32LowerCaseNoPadding, encodeHexLowerCase } from '@oslojs/encoding';
7
6
import { sha256 } from '@oslojs/crypto/sha2';
8
7
import { DAY } from '@atproto/common';
9
-
import { sessionStore } from '$lib/server/db/schema';
10
8
import type { RequestEvent } from '@sveltejs/kit';
11
-
import { eq } from 'drizzle-orm';
12
9
import { Agent } from '@atproto/api';
13
10
import type { NodeOAuthClient } from '@atproto/oauth-client-node';
14
11
import { logger } from '$lib/server/logger';
12
+
import { Cache, getAValKeyClient } from '$lib/server/cache';
13
+
14
+
const COOKIE_SESSION_STORE = 'cookie_sessions:';
15
15
16
16
export class SessionRestorationError extends Error {
17
17
constructor(message: string) {
···
23
23
// This is a sliding expiration for the cookie session. Can change it if you want it to be less or more.
24
24
// The actual atproto session goes for a while if it's a confidential client as long as it's refreshed
25
25
// https://atproto.com/specs/oauth#tokens-and-session-lifetime
26
-
const DEFAULT_EXPIRY = 30 * DAY;
26
+
const DEFAULT_EXPIRY_MS = 30 * DAY;
27
+
const DEFAULT_EXPIRY_SECONDS = Math.floor(DEFAULT_EXPIRY_MS / 1000);
28
+
27
29
28
30
const NULL_SESSION_RESPONSE = { atpAgent: null, did: null, handle: null };
29
31
32
+
interface StoredSession {
33
+
did: string;
34
+
handle: string;
35
+
createdAt: number; // timestamp in milliseconds
36
+
}
30
37
31
38
export class Session {
32
-
db: typeof db;
39
+
cache: Cache;
33
40
atpOAuthClient: NodeOAuthClient;
34
41
35
-
constructor(database: typeof db, oauthClient: NodeOAuthClient) {
36
-
this.db = database;
42
+
constructor(cache: Cache, oauthClient: NodeOAuthClient) {
43
+
this.cache = cache;
37
44
this.atpOAuthClient = oauthClient;
38
45
}
39
46
40
47
41
48
async validateSessionToken(token: string): Promise<SessionValidationResult> {
42
49
const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
43
-
const result = await this.db.select().from(sessionStore).where(eq(sessionStore.id, sessionId)).limit(1);
44
-
if(result.length > 1){
45
-
throw new Error('Multiple sessions found for token. Should not happen');
46
-
}
47
-
if(result.length === 0){
48
-
return NULL_SESSION_RESPONSE;
49
-
}
50
-
const session = result[0];
50
+
const sessionData = await this.cache.get(sessionId);
51
51
52
-
if (Date.now() >= session.expiresAt.getTime()) {
53
-
await this.invalidateSession(session.id);
54
-
logger.warn(`Session expired for the did: ${session.did}`);
52
+
// If session doesn't exist or has expired, Valkey returns undefined
53
+
if (!sessionData) {
55
54
return NULL_SESSION_RESPONSE;
56
55
}
57
-
if (Date.now() >= session.expiresAt.getTime() - 1000 * 60 * 60 * 24 * 15) {
58
-
session.expiresAt = new Date(Date.now() + DEFAULT_EXPIRY);
59
-
await this.db.update(sessionStore).set(session).where(eq(sessionStore.id, sessionId));
60
-
}
61
-
try{
56
+
57
+
const session: StoredSession = JSON.parse(sessionData);
58
+
59
+
// Refresh TTL on each access (sliding window)
60
+
await this.cache.refreshExpiry(sessionId, DEFAULT_EXPIRY_SECONDS);
61
+
62
+
try {
62
63
const oAuthSession = await this.atpOAuthClient.restore(session.did);
63
-
64
64
const agent = new Agent(oAuthSession);
65
65
return { atpAgent: agent, did: session.did, handle: session.handle };
66
-
}catch (err){
66
+
} catch (err) {
67
67
const errorMessage = (err as Error).message;
68
68
logger.warn(`Error restoring session for did: ${session.did}, error: ${errorMessage}`);
69
-
//Counting any error when restoring a session as a failed session resume and deleting the users web browser session
69
+
//Counting any error when restoring a session as a failed session resume and deleting the user's web browser session
70
70
//You can go further and capture different types of errors
71
-
await this.invalidateUserSessions(session.did);
71
+
await this.invalidateSession(sessionId);
72
72
throw new SessionRestorationError(`Failed to restore your session: ${errorMessage}. Please log in again.`);
73
73
}
74
74
}
75
75
76
-
private setSessionTokenCookie(event: RequestEvent, token: string, expiresAt: Date): void {
76
+
private setSessionTokenCookie(event: RequestEvent, token: string): void {
77
77
event.cookies.set('session', token, {
78
78
httpOnly: true,
79
79
path: '/',
80
80
secure: import.meta.env.PROD,
81
81
sameSite: 'lax',
82
-
expires: expiresAt
82
+
maxAge: DEFAULT_EXPIRY_SECONDS
83
83
});
84
84
}
85
85
···
94
94
}
95
95
96
96
async invalidateSession(sessionId: string) {
97
-
await this.db.delete(sessionStore).where(eq(sessionStore.id, sessionId));
97
+
await this.cache.delete(sessionId);
98
98
}
99
99
100
100
async invalidateSessionByToken(token: string) {
···
102
102
await this.invalidateSession(sessionId);
103
103
}
104
104
105
-
async invalidateUserSessions(did: string) {
106
-
await this.db.delete(sessionStore).where(eq(sessionStore.did, did));
107
-
}
108
-
109
105
private async createSession(token: string, did: string, handle: string) {
110
106
const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
111
-
const expiresAt = new Date(Date.now() + DEFAULT_EXPIRY);
112
-
const session = { id: sessionId, did, handle, expiresAt, createdAt: new Date() };
113
-
await this.db.insert(sessionStore).values(session);
107
+
const session: StoredSession = {
108
+
did,
109
+
handle,
110
+
createdAt: Date.now()
111
+
};
112
+
// Set with TTL - Valkey will automatically expire after DEFAULT_EXPIRY_SECONDS
113
+
await this.cache.set(sessionId, JSON.stringify(session), DEFAULT_EXPIRY_SECONDS);
114
114
return session;
115
115
}
116
116
···
123
123
async createAndSetSession(event: RequestEvent, did: string, handle: string) {
124
124
const token = this.generateSessionToken();
125
125
const session = await this.createSession(token, did, handle);
126
-
this.setSessionTokenCookie(event, token, session.expiresAt);
126
+
this.setSessionTokenCookie(event, token);
127
127
return session;
128
128
}
129
129
···
150
150
export const getSessionManager = async (): Promise<Session> => {
151
151
if (!sessionManager) {
152
152
sessionManager = (async () => {
153
+
const valKeyClient = await getAValKeyClient();
154
+
const cache = new Cache(valKeyClient, COOKIE_SESSION_STORE);
153
155
const client = await atpOAuthClient();
154
-
return new Session(db, client);
156
+
return new Session(cache, client);
155
157
})();
156
158
}
157
159
return sessionManager;
+2
-2
src/routes/logout/+page.server.ts
+2
-2
src/routes/logout/+page.server.ts
···
13
13
sessionManager.deleteSessionTokenCookie(event);
14
14
15
15
const oauthClient = await atpOAuthClient();
16
-
if(event.locals.did) {
17
-
await oauthClient.revoke(event.locals.did);
16
+
if(event.locals.session?.did) {
17
+
await oauthClient.revoke(event.locals.session.did);
18
18
}
19
19
}
20
20