forked from
baileytownsend.dev/atproto-sveltekit-template
A build your own ATProto adventure, OAuth already figured out for you.
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
3
4import { db } from './db';
5import { atpOAuthClient } from './atproto/client';
6import { encodeBase32LowerCaseNoPadding, encodeHexLowerCase } from '@oslojs/encoding';
7import { sha256 } from '@oslojs/crypto/sha2';
8import { DAY } from '@atproto/common';
9import { sessionStore } from '$lib/server/db/schema';
10import type { RequestEvent } from '@sveltejs/kit';
11import { eq } from 'drizzle-orm';
12import { Agent } from '@atproto/api';
13import type { NodeOAuthClient } from '@atproto/oauth-client-node';
14import { logger } from '$lib/server/logger';
15
16export class SessionRestorationError extends Error {
17 constructor(message: string) {
18 super(message);
19 this.name = 'SessionRestorationError';
20 }
21}
22
23// This is a sliding expiration for the cookie session. Can change it if you want it to be less or more.
24// The actual atproto session goes for a while if it's a confidential client as long as it's refreshed
25// https://atproto.com/specs/oauth#tokens-and-session-lifetime
26const DEFAULT_EXPIRY = 30 * DAY;
27
28const NULL_SESSION_RESPONSE = { atpAgent: null, did: null, handle: null };
29
30
31export class Session {
32 db: typeof db;
33 atpOAuthClient: NodeOAuthClient;
34
35 constructor(database: typeof db, oauthClient: NodeOAuthClient) {
36 this.db = database;
37 this.atpOAuthClient = oauthClient;
38 }
39
40
41 async validateSessionToken(token: string): Promise<SessionValidationResult> {
42 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];
51
52 if (Date.now() >= session.expiresAt.getTime()) {
53 await this.invalidateSession(session.id);
54 logger.warn(`Session expired for the did: ${session.did}`);
55 return NULL_SESSION_RESPONSE;
56 }
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{
62 const oAuthSession = await this.atpOAuthClient.restore(session.did);
63
64 const agent = new Agent(oAuthSession);
65 return { atpAgent: agent, did: session.did, handle: session.handle };
66 }catch (err){
67 const errorMessage = (err as Error).message;
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
70 //You can go further and capture different types of errors
71 await this.invalidateUserSessions(session.did);
72 throw new SessionRestorationError(`Failed to restore your session: ${errorMessage}. Please log in again.`);
73 }
74 }
75
76 private setSessionTokenCookie(event: RequestEvent, token: string, expiresAt: Date): void {
77 event.cookies.set('session', token, {
78 httpOnly: true,
79 path: '/',
80 secure: import.meta.env.PROD,
81 sameSite: 'lax',
82 expires: expiresAt
83 });
84 }
85
86 deleteSessionTokenCookie(event: RequestEvent): void {
87 event.cookies.set('session', '', {
88 httpOnly: true,
89 path: '/',
90 secure: import.meta.env.PROD,
91 sameSite: 'lax',
92 maxAge: 0
93 });
94 }
95
96 async invalidateSession(sessionId: string) {
97 await this.db.delete(sessionStore).where(eq(sessionStore.id, sessionId));
98 }
99
100 async invalidateSessionByToken(token: string) {
101 const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
102 await this.invalidateSession(sessionId);
103 }
104
105 async invalidateUserSessions(did: string) {
106 await this.db.delete(sessionStore).where(eq(sessionStore.did, did));
107 }
108
109 private async createSession(token: string, did: string, handle: string) {
110 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);
114 return session;
115 }
116
117 private generateSessionToken(): string {
118 const tokenBytes = new Uint8Array(20);
119 crypto.getRandomValues(tokenBytes);
120 return encodeBase32LowerCaseNoPadding(tokenBytes);
121 }
122
123 async createAndSetSession(event: RequestEvent, did: string, handle: string) {
124 const token = this.generateSessionToken();
125 const session = await this.createSession(token, did, handle);
126 this.setSessionTokenCookie(event, token, session.expiresAt);
127 return session;
128 }
129
130 async getSessionFromRequest(event: RequestEvent): Promise<SessionValidationResult> {
131 const token = event.cookies.get('session');
132 if (!token) {
133 return NULL_SESSION_RESPONSE;
134 }
135 try {
136 return await this.validateSessionToken(token);
137 } catch (err) {
138 //We delete the cookie on any error and pass along the error
139 this.deleteSessionTokenCookie(event);
140 throw err;
141 }
142 }
143
144}
145
146type SessionValidationResult = { atpAgent: Agent, did: string, handle: string } | { atpAgent: null, did: null, handle: null };
147
148let sessionManager: Promise<Session> | null = null;
149
150export const getSessionManager = async (): Promise<Session> => {
151 if (!sessionManager) {
152 sessionManager = (async () => {
153 const client = await atpOAuthClient();
154 return new Session(db, client);
155 })();
156 }
157 return sessionManager;
158};