Highly ambitious ATProtocol AppView service and sdks

bring in oauth and session packages from other repos

+2197
+20
packages/oauth/LICENSE
··· 1 + MIT License 2 + 3 + Copyright (c) 2025 Slices Network 4 + 5 + Permission is hereby granted, free of charge, to any person obtaining a copy of 6 + this software and associated documentation files (the "Software"), to deal in 7 + the Software without restriction, including without limitation the rights to 8 + use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 + the Software, and to permit persons to whom the Software is furnished to do so, 10 + subject to the following conditions: 11 + 12 + The above copyright notice and this permission notice shall be included in all 13 + copies or substantial portions of the Software. 14 + 15 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 + FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 + COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 + IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 + CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+57
packages/oauth/README.md
··· 1 + # @slices/oauth 2 + 3 + [AIP](https://github.com/graze-social/aip) OAuth 2.1 client with PKCE support 4 + for Typescript. 5 + 6 + ## Features 7 + 8 + - OAuth 2.1 with PKCE flow 9 + - Token refresh handling 10 + - Pluggable storage backends (SQLite, Deno KV, others can be added) 11 + 12 + ## Installation 13 + 14 + ### Deno 15 + 16 + ```bash 17 + deno add jsr:@slices/oauth 18 + ``` 19 + 20 + ```typescript 21 + import { OAuthClient, SQLiteOAuthStorage } from "@slices/oauth"; 22 + ``` 23 + 24 + For node based package managers: 25 + 26 + ```bash 27 + # pnpm (10.9+) 28 + pnpm install jsr:@slices/oauth 29 + 30 + # Yarn (4.9+) 31 + yarn add jsr:@slices/oauth 32 + ``` 33 + 34 + ## Usage 35 + 36 + ```typescript 37 + import { OAuthClient, SQLiteOAuthStorage } from "@slices/oauth"; 38 + 39 + // Set up storage 40 + const storage = new SQLiteOAuthStorage("oauth.db"); 41 + 42 + // Create OAuth client 43 + const client = new OAuthClient({ 44 + clientId: "your-client-id", 45 + clientSecret: "your-client-secret", 46 + authBaseUrl: "https://auth.example.com", 47 + redirectUri: "http://localhost:8000/oauth/callback", 48 + scopes: ["atproto:atproto"], 49 + }, storage); 50 + 51 + // Start authorization flow 52 + const result = await client.authorize({ loginHint: "user.bsky.social" }); 53 + // Redirect user to result.authorizationUrl 54 + 55 + // Handle callback 56 + const tokens = await client.handleCallback({ code, state }); 57 + ```
+8
packages/oauth/deno.json
··· 1 + { 2 + "name": "@slices/oauth", 3 + "version": "0.4.1", 4 + "exports": "./mod.ts", 5 + "tasks": { 6 + "test": "deno test" 7 + } 8 + }
+16
packages/oauth/mod.ts
··· 1 + export { OAuthClient } from "./src/client.ts"; 2 + export { PKCEUtils } from "./src/pkce.ts"; 3 + export { DenoKVOAuthStorage } from "./src/storage/deno-kv.ts"; 4 + export { SQLiteOAuthStorage } from "./src/storage/sqlite.ts"; 5 + export type { 6 + OAuthConfig, 7 + OAuthTokens, 8 + OAuthAuthorizeParams, 9 + OAuthAuthorizeResult, 10 + OAuthCallbackParams, 11 + OAuthCallbackRequest, 12 + OAuthTokenResponse, 13 + OAuthStorage, 14 + OAuthUserInfo, 15 + PKCEChallenge, 16 + } from "./src/types.ts";
+308
packages/oauth/src/client.ts
··· 1 + import type { 2 + OAuthConfig, 3 + OAuthTokens, 4 + OAuthAuthorizeResult, 5 + OAuthTokenResponse, 6 + OAuthStorage, 7 + OAuthUserInfo, 8 + } from "./types.ts"; 9 + import { PKCEUtils } from "./pkce.ts"; 10 + 11 + export class OAuthClient { 12 + private config: OAuthConfig; 13 + private storage: OAuthStorage; 14 + private refreshPromise?: Promise<void>; 15 + private forceRefresh = false; // Flag to force refresh regardless of expiry 16 + 17 + constructor(config: OAuthConfig, storage: OAuthStorage) { 18 + this.config = config; 19 + this.storage = storage; 20 + } 21 + 22 + async authorize(params: { 23 + loginHint: string; 24 + state?: string; 25 + scope?: string; 26 + }): Promise<OAuthAuthorizeResult> { 27 + const pkce = await PKCEUtils.generatePKCEChallenge(); 28 + const state = params.state || PKCEUtils.generateState(); 29 + 30 + const parParams = { 31 + client_id: this.config.clientId, 32 + response_type: "code", 33 + redirect_uri: this.config.redirectUri, 34 + state, 35 + code_challenge: pkce.codeChallenge, 36 + code_challenge_method: pkce.codeChallengeMethod, 37 + scope: 38 + params.scope || 39 + this.config.scopes?.join(" ") || 40 + "atproto:atproto atproto:transition:generic", 41 + login_hint: params.loginHint, 42 + }; 43 + 44 + const parResponse = await this.makeRequest<{ request_uri: string }>( 45 + "oauth/par", 46 + "POST", 47 + parParams, 48 + false 49 + ); 50 + 51 + await this.storage.setState(state, pkce.codeVerifier); 52 + 53 + const authParams = new URLSearchParams({ 54 + client_id: this.config.clientId, 55 + request_uri: parResponse.request_uri, 56 + }); 57 + 58 + const authorizationUrl = `${ 59 + this.config.authBaseUrl 60 + }/oauth/authorize?${authParams.toString()}`; 61 + 62 + return { 63 + authorizationUrl, 64 + codeVerifier: pkce.codeVerifier, 65 + state, 66 + }; 67 + } 68 + 69 + async handleCallback(params: { 70 + code: string; 71 + state: string; 72 + }): Promise<OAuthTokens> { 73 + // Retrieve the code verifier from storage using the state 74 + const codeVerifier = await this.storage.getState(params.state); 75 + if (!codeVerifier) { 76 + throw new Error("Invalid or expired OAuth state"); 77 + } 78 + 79 + const tokenResponse = await this.makeRequest<OAuthTokenResponse>( 80 + "oauth/token", 81 + "POST", 82 + { 83 + grant_type: "authorization_code", 84 + code: params.code, 85 + redirect_uri: this.config.redirectUri, 86 + client_id: this.config.clientId, 87 + client_secret: this.config.clientSecret, 88 + code_verifier: codeVerifier, 89 + }, 90 + false 91 + ); 92 + 93 + const tokens = this.transformTokenResponse(tokenResponse); 94 + await this.storage.setTokens(tokens); 95 + await this.storage.clearState(params.state); 96 + 97 + return tokens; 98 + } 99 + 100 + async refreshAccessToken(): Promise<OAuthTokens> { 101 + const tokens = await this.storage.getTokens(); 102 + if (!tokens?.refreshToken) { 103 + throw new Error("No refresh token available"); 104 + } 105 + 106 + try { 107 + const tokenResponse = await this.makeRequest<OAuthTokenResponse>( 108 + "oauth/token", 109 + "POST", 110 + { 111 + grant_type: "refresh_token", 112 + refresh_token: tokens.refreshToken, 113 + client_id: this.config.clientId, 114 + client_secret: this.config.clientSecret, 115 + }, 116 + false 117 + ); 118 + 119 + const newTokens = this.transformTokenResponse(tokenResponse); 120 + await this.storage.setTokens(newTokens); 121 + return newTokens; 122 + } catch (error) { 123 + await this.storage.clearTokens(); 124 + throw new Error(`Failed to refresh token: ${error}`); 125 + } 126 + } 127 + 128 + async ensureValidToken(): Promise<OAuthTokens> { 129 + const tokens = await this.storage.getTokens(); 130 + 131 + if (!tokens) { 132 + throw new Error("No access token available. Please authenticate first."); 133 + } 134 + 135 + // Check if we need to refresh (either expired or force refresh flag is set) 136 + if (!this.forceRefresh && !this.isTokenExpired(tokens)) { 137 + return tokens; 138 + } 139 + 140 + if (!tokens.refreshToken) { 141 + throw new Error( 142 + "Access token expired and no refresh token available. Please re-authenticate." 143 + ); 144 + } 145 + 146 + if (this.refreshPromise) { 147 + await this.refreshPromise; 148 + const refreshedTokens = await this.storage.getTokens(); 149 + if (!refreshedTokens) { 150 + throw new Error("Failed to refresh tokens"); 151 + } 152 + this.forceRefresh = false; // Reset flag after successful refresh 153 + return refreshedTokens; 154 + } 155 + 156 + this.refreshPromise = this.refreshAccessToken().then(() => undefined); 157 + try { 158 + await this.refreshPromise; 159 + const refreshedTokens = await this.storage.getTokens(); 160 + if (!refreshedTokens) { 161 + throw new Error("Failed to refresh tokens"); 162 + } 163 + this.forceRefresh = false; // Reset flag after successful refresh 164 + return refreshedTokens; 165 + } finally { 166 + this.refreshPromise = undefined; 167 + } 168 + } 169 + 170 + async getUserInfo(): Promise<OAuthUserInfo | null> { 171 + const isAuthenticated = await this.isAuthenticated(); 172 + if (!isAuthenticated) { 173 + return null; 174 + } 175 + 176 + try { 177 + const userInfo = await this.makeRequest<OAuthUserInfo>( 178 + "oauth/userinfo", 179 + "GET", 180 + undefined, 181 + true 182 + ); 183 + return userInfo; 184 + } catch (error) { 185 + console.error("Failed to fetch user info:", error); 186 + return null; 187 + } 188 + } 189 + 190 + async isAuthenticated(): Promise<boolean> { 191 + const tokens = await this.storage.getTokens(); 192 + return !!tokens?.accessToken; 193 + } 194 + 195 + async logout(): Promise<void> { 196 + await this.storage.clearTokens(); 197 + } 198 + 199 + /** 200 + * Marks the current token as invalid, forcing the next ensureValidToken() 201 + * call to refresh regardless of expiry time. Use this when the server 202 + * rejects a token with 401 Unauthorized. 203 + */ 204 + invalidateCurrentToken(): void { 205 + this.forceRefresh = true; 206 + } 207 + 208 + async getAuthenticationInfo(): Promise<{ 209 + isAuthenticated: boolean; 210 + expiresAt?: number; 211 + scope?: string; 212 + }> { 213 + const tokens = await this.storage.getTokens(); 214 + return { 215 + isAuthenticated: !!tokens?.accessToken, 216 + expiresAt: tokens?.expiresAt, 217 + scope: tokens?.scope, 218 + }; 219 + } 220 + 221 + private isTokenExpired(tokens: OAuthTokens): boolean { 222 + if (!tokens.expiresAt) return false; 223 + return Date.now() >= tokens.expiresAt - 60000; // 60 second buffer 224 + } 225 + 226 + private transformTokenResponse(response: OAuthTokenResponse): OAuthTokens { 227 + const tokenType = response.token_type 228 + ? response.token_type.charAt(0).toUpperCase() + 229 + response.token_type.slice(1).toLowerCase() 230 + : "Bearer"; 231 + 232 + return { 233 + accessToken: response.access_token, 234 + refreshToken: response.refresh_token, 235 + tokenType, 236 + scope: response.scope, 237 + expiresAt: response.expires_in 238 + ? Date.now() + response.expires_in * 1000 239 + : undefined, 240 + expiresIn: response.expires_in, 241 + }; 242 + } 243 + 244 + private async makeRequest<T = unknown>( 245 + endpoint: string, 246 + method: "GET" | "POST", 247 + params?: Record<string, string | undefined>, 248 + requiresAuth: boolean = false 249 + ): Promise<T> { 250 + const url = `${this.config.authBaseUrl}/${endpoint}`; 251 + 252 + const requestInit: RequestInit = { 253 + method, 254 + headers: {}, 255 + }; 256 + 257 + if (requiresAuth) { 258 + const tokens = await this.ensureValidToken(); 259 + (requestInit.headers as Record<string, string>)[ 260 + "Authorization" 261 + ] = `${tokens.tokenType} ${tokens.accessToken}`; 262 + } else if (endpoint === "oauth/par" || endpoint === "oauth/token") { 263 + const credentials = btoa( 264 + `${this.config.clientId}:${this.config.clientSecret}` 265 + ); 266 + (requestInit.headers as Record<string, string>)[ 267 + "Authorization" 268 + ] = `Basic ${credentials}`; 269 + } 270 + 271 + if (method === "GET" && params) { 272 + const searchParams = new URLSearchParams(); 273 + Object.entries(params).forEach(([key, value]) => { 274 + if (value !== undefined && value !== null) { 275 + searchParams.append(key, String(value)); 276 + } 277 + }); 278 + const queryString = searchParams.toString(); 279 + if (queryString) { 280 + const urlWithParams = `${url}?${queryString}`; 281 + const response = await fetch(urlWithParams, requestInit); 282 + if (!response.ok) { 283 + throw new Error( 284 + `Request failed: ${response.status} ${response.statusText}` 285 + ); 286 + } 287 + return (await response.json()) as T; 288 + } 289 + } else if (method === "POST" && params) { 290 + (requestInit.headers as Record<string, string>)["Content-Type"] = 291 + "application/x-www-form-urlencoded"; 292 + requestInit.body = new URLSearchParams(params as Record<string, string>); 293 + } 294 + 295 + const response = await fetch(url, requestInit); 296 + if (!response.ok) { 297 + throw new Error( 298 + `Request failed: ${response.status} ${response.statusText}` 299 + ); 300 + } 301 + 302 + return (await response.json()) as T; 303 + } 304 + 305 + async getTokens(): Promise<OAuthTokens | null> { 306 + return await this.storage.getTokens(); 307 + } 308 + }
+43
packages/oauth/src/pkce.ts
··· 1 + import type { PKCEChallenge } from "./types.ts"; 2 + 3 + export class PKCEUtils { 4 + static generateCodeVerifier(): string { 5 + const array = new Uint8Array(32); 6 + crypto.getRandomValues(array); 7 + return btoa(String.fromCharCode.apply(null, Array.from(array))) 8 + .replace(/\+/g, "-") 9 + .replace(/\//g, "_") 10 + .replace(/=/g, ""); 11 + } 12 + 13 + static async generateCodeChallenge(verifier: string): Promise<string> { 14 + const encoder = new TextEncoder(); 15 + const data = encoder.encode(verifier); 16 + const digest = await crypto.subtle.digest("SHA-256", data); 17 + return btoa( 18 + String.fromCharCode.apply(null, Array.from(new Uint8Array(digest))) 19 + ) 20 + .replace(/\+/g, "-") 21 + .replace(/\//g, "_") 22 + .replace(/=/g, ""); 23 + } 24 + 25 + static async generatePKCEChallenge(): Promise<PKCEChallenge> { 26 + const codeVerifier = this.generateCodeVerifier(); 27 + const codeChallenge = await this.generateCodeChallenge(codeVerifier); 28 + return { 29 + codeVerifier, 30 + codeChallenge, 31 + codeChallengeMethod: "S256", 32 + }; 33 + } 34 + 35 + static generateState(): string { 36 + const array = new Uint8Array(16); 37 + crypto.getRandomValues(array); 38 + return btoa(String.fromCharCode.apply(null, Array.from(array))) 39 + .replace(/\+/g, "-") 40 + .replace(/\//g, "_") 41 + .replace(/=/g, ""); 42 + } 43 + }
+66
packages/oauth/src/storage/deno-kv.ts
··· 1 + import type { OAuthStorage, OAuthTokens } from "../types.ts"; 2 + 3 + interface OAuthState { 4 + codeVerifier: string; 5 + timestamp: number; 6 + } 7 + 8 + export class DenoKVOAuthStorage implements OAuthStorage { 9 + constructor(private kv: Deno.Kv) {} 10 + 11 + async getTokens(): Promise<OAuthTokens | null> { 12 + const result = await this.kv.get<OAuthTokens>(["oauth_tokens"]); 13 + return result.value; 14 + } 15 + 16 + async setTokens(tokens: OAuthTokens): Promise<void> { 17 + const expirationMs = 30 * 24 * 60 * 60 * 1000; // 30 days 18 + await this.kv.set(["oauth_tokens"], tokens, { expireIn: expirationMs }); 19 + } 20 + 21 + async clearTokens(): Promise<void> { 22 + await this.kv.delete(["oauth_tokens"]); 23 + } 24 + 25 + async getState(state: string): Promise<string | null> { 26 + const result = await this.kv.get<OAuthState>(["oauth_states", state]); 27 + 28 + if (!result.value) return null; 29 + 30 + // Delete after use (one-time use) 31 + await this.kv.delete(["oauth_states", state]); 32 + 33 + return result.value.codeVerifier; 34 + } 35 + 36 + async setState(state: string, codeVerifier: string): Promise<void> { 37 + const stateData: OAuthState = { 38 + codeVerifier, 39 + timestamp: Date.now(), 40 + }; 41 + 42 + // Store with 10 minute expiration 43 + await this.kv.set( 44 + ["oauth_states", state], 45 + stateData, 46 + { expireIn: 10 * 60 * 1000 } 47 + ); 48 + 49 + // Auto-cleanup expired states 50 + await this.cleanup(); 51 + } 52 + 53 + async clearState(state: string): Promise<void> { 54 + await this.kv.delete(["oauth_states", state]); 55 + } 56 + 57 + private async cleanup(): Promise<void> { 58 + const cutoff = Date.now() - (10 * 60 * 1000); // 10 minutes ago 59 + 60 + for await (const entry of this.kv.list<OAuthState>({ prefix: ["oauth_states"] })) { 61 + if (entry.value.timestamp < cutoff) { 62 + await this.kv.delete(entry.key); 63 + } 64 + } 65 + } 66 + }
+134
packages/oauth/src/storage/sqlite.ts
··· 1 + import { DatabaseSync } from "node:sqlite"; 2 + import type { OAuthStorage, OAuthTokens } from "../types.ts"; 3 + 4 + export class SQLiteOAuthStorage implements OAuthStorage { 5 + private db: DatabaseSync; 6 + 7 + constructor(dbPath: string = ":memory:") { 8 + this.db = new DatabaseSync(dbPath); 9 + this.initTables(); 10 + } 11 + 12 + private initTables(): void { 13 + // Create tokens table 14 + this.db.exec(` 15 + CREATE TABLE IF NOT EXISTS oauth_tokens ( 16 + id INTEGER PRIMARY KEY, 17 + access_token TEXT NOT NULL, 18 + token_type TEXT NOT NULL, 19 + expires_at INTEGER, 20 + refresh_token TEXT, 21 + scope TEXT, 22 + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now') * 1000) 23 + ) 24 + `); 25 + 26 + // Create states table with automatic cleanup 27 + this.db.exec(` 28 + CREATE TABLE IF NOT EXISTS oauth_states ( 29 + state TEXT PRIMARY KEY, 30 + code_verifier TEXT NOT NULL, 31 + timestamp INTEGER NOT NULL DEFAULT (strftime('%s', 'now') * 1000) 32 + ) 33 + `); 34 + 35 + // Create index for cleanup efficiency 36 + this.db.exec(` 37 + CREATE INDEX IF NOT EXISTS idx_oauth_states_timestamp ON oauth_states(timestamp) 38 + `); 39 + } 40 + 41 + getTokens(): Promise<OAuthTokens | null> { 42 + const stmt = this.db.prepare(` 43 + SELECT access_token, token_type, expires_at, refresh_token, scope 44 + FROM oauth_tokens 45 + ORDER BY created_at DESC 46 + LIMIT 1 47 + `); 48 + 49 + const row = stmt.get() as { 50 + access_token: string; 51 + token_type: string; 52 + expires_at?: number; 53 + refresh_token?: string; 54 + scope?: string; 55 + } | undefined; 56 + if (!row) return Promise.resolve(null); 57 + 58 + return Promise.resolve({ 59 + accessToken: row.access_token, 60 + tokenType: row.token_type, 61 + expiresAt: row.expires_at, 62 + refreshToken: row.refresh_token, 63 + scope: row.scope, 64 + }); 65 + } 66 + 67 + async setTokens(tokens: OAuthTokens): Promise<void> { 68 + // Clear existing tokens first 69 + await this.clearTokens(); 70 + 71 + const stmt = this.db.prepare(` 72 + INSERT INTO oauth_tokens (access_token, token_type, expires_at, refresh_token, scope) 73 + VALUES (?, ?, ?, ?, ?) 74 + `); 75 + 76 + stmt.run( 77 + tokens.accessToken, 78 + tokens.tokenType, 79 + tokens.expiresAt || null, 80 + tokens.refreshToken || null, 81 + tokens.scope || null 82 + ); 83 + } 84 + 85 + clearTokens(): Promise<void> { 86 + const stmt = this.db.prepare("DELETE FROM oauth_tokens"); 87 + stmt.run(); 88 + return Promise.resolve(); 89 + } 90 + 91 + async getState(state: string): Promise<string | null> { 92 + const stmt = this.db.prepare(` 93 + SELECT code_verifier FROM oauth_states WHERE state = ? 94 + `); 95 + 96 + const row = stmt.get(state) as { code_verifier: string } | undefined; 97 + if (!row) return null; 98 + 99 + // Delete after use (one-time use) 100 + await this.clearState(state); 101 + 102 + return row.code_verifier; 103 + } 104 + 105 + setState(state: string, codeVerifier: string): Promise<void> { 106 + const stmt = this.db.prepare(` 107 + INSERT OR REPLACE INTO oauth_states (state, code_verifier, timestamp) 108 + VALUES (?, ?, ?) 109 + `); 110 + 111 + stmt.run(state, codeVerifier, Date.now()); 112 + 113 + // Auto-cleanup expired states 114 + this.cleanup(); 115 + 116 + return Promise.resolve(); 117 + } 118 + 119 + clearState(state: string): Promise<void> { 120 + const stmt = this.db.prepare("DELETE FROM oauth_states WHERE state = ?"); 121 + stmt.run(state); 122 + return Promise.resolve(); 123 + } 124 + 125 + private cleanup(): void { 126 + const cutoff = Date.now() - (10 * 60 * 1000); // 10 minutes ago 127 + const stmt = this.db.prepare("DELETE FROM oauth_states WHERE timestamp < ?"); 128 + stmt.run(cutoff); 129 + } 130 + 131 + close(): void { 132 + this.db.close(); 133 + } 134 + }
+74
packages/oauth/src/types.ts
··· 1 + export interface OAuthConfig { 2 + clientId: string; 3 + clientSecret: string; 4 + authBaseUrl: string; 5 + redirectUri: string; 6 + scopes?: string[]; 7 + } 8 + 9 + export interface OAuthTokens { 10 + accessToken: string; 11 + refreshToken?: string; 12 + tokenType: string; 13 + expiresIn?: number; 14 + expiresAt?: number; 15 + scope?: string; 16 + } 17 + 18 + export interface OAuthAuthorizeParams { 19 + loginHint: string; 20 + redirectUri: string; 21 + scope?: string; 22 + state?: string; 23 + } 24 + 25 + export interface OAuthAuthorizeResult { 26 + authorizationUrl: string; 27 + codeVerifier: string; 28 + state: string; 29 + } 30 + 31 + export interface OAuthCallbackParams { 32 + code: string; 33 + state: string; 34 + codeVerifier: string; 35 + redirectUri: string; 36 + } 37 + 38 + export interface OAuthCallbackRequest { 39 + code: string; 40 + state: string; 41 + redirectUri: string; 42 + } 43 + 44 + export interface OAuthTokenResponse { 45 + access_token: string; 46 + token_type: string; 47 + expires_in?: number; 48 + refresh_token?: string; 49 + scope?: string; 50 + } 51 + 52 + export interface PKCEChallenge { 53 + codeVerifier: string; 54 + codeChallenge: string; 55 + codeChallengeMethod: "S256"; 56 + } 57 + 58 + export interface OAuthStorage { 59 + getTokens(): Promise<OAuthTokens | null>; 60 + setTokens(tokens: OAuthTokens): Promise<void>; 61 + clearTokens(): Promise<void>; 62 + 63 + getState(state: string): Promise<string | null>; 64 + setState(state: string, codeVerifier: string): Promise<void>; 65 + clearState(state: string): Promise<void>; 66 + } 67 + 68 + export interface OAuthUserInfo { 69 + sub: string; 70 + did: string; 71 + name?: string; 72 + profile?: string; 73 + pds_endpoint?: string; 74 + }
+1
packages/session/.gitignore
··· 1 + node_modules
+20
packages/session/LICENSE
··· 1 + MIT License 2 + 3 + Copyright (c) 2025 Slices Network 4 + 5 + Permission is hereby granted, free of charge, to any person obtaining a copy of 6 + this software and associated documentation files (the "Software"), to deal in 7 + the Software without restriction, including without limitation the rights to 8 + use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 + the Software, and to permit persons to whom the Software is furnished to do so, 10 + subject to the following conditions: 11 + 12 + The above copyright notice and this permission notice shall be included in all 13 + copies or substantial portions of the Software. 14 + 15 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 + FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 + COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 + IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 + CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+217
packages/session/README.md
··· 1 + # @slices/session 2 + 3 + Session management for Slice applications with OAuth integration. 4 + 5 + ## Features 6 + 7 + - **Multiple Storage Adapters**: Memory, SQLite, PostgreSQL 8 + - **OAuth Integration**: Works seamlessly with `@slices/oauth` 9 + - **Automatic Token Refresh**: Keeps OAuth tokens valid 10 + - **Secure Cookie Handling**: HttpOnly, Secure, SameSite defaults 11 + - **Automatic Cleanup**: Expired session cleanup 12 + - **Framework Agnostic**: Works with any Deno web framework 13 + 14 + ## Installation 15 + 16 + ```bash 17 + deno add @slices/session 18 + ``` 19 + 20 + ## Quick Start 21 + 22 + ### Basic Usage 23 + 24 + ```typescript 25 + import { SessionStore, SQLiteAdapter } from "@slices/session"; 26 + 27 + const sessionStore = new SessionStore({ 28 + adapter: new SQLiteAdapter("./sessions.db"), 29 + cookieOptions: { 30 + httpOnly: true, 31 + secure: true, 32 + sameSite: "lax" 33 + } 34 + }); 35 + 36 + // Create a session 37 + const sessionId = await sessionStore.createSession("user123", "alice.bsky.social"); 38 + 39 + // Get session from request 40 + const session = await sessionStore.getSessionFromRequest(request); 41 + if (session) { 42 + console.log("User:", session.handle); 43 + } 44 + ``` 45 + 46 + ### With OAuth Integration 47 + 48 + ```typescript 49 + import { SessionStore, SQLiteAdapter, withOAuthSession } from "@slices/session"; 50 + import { OAuthClient } from "@slices/oauth"; 51 + 52 + const sessionStore = new SessionStore({ 53 + adapter: new SQLiteAdapter("./sessions.db") 54 + }); 55 + 56 + const oauthClient = new OAuthClient({ 57 + clientId: "your-client-id", 58 + clientSecret: "your-client-secret", 59 + authBaseUrl: "https://auth.example.com" 60 + }); 61 + 62 + const oauthSessions = withOAuthSession(sessionStore, oauthClient); 63 + 64 + // Create OAuth session 65 + const sessionId = await oauthSessions.createOAuthSession(request); 66 + 67 + // Get session with auto token refresh 68 + const session = await oauthSessions.getOAuthSession(sessionId); 69 + ``` 70 + 71 + ### Storage Adapters 72 + 73 + #### Memory Adapter (Development) 74 + 75 + ```typescript 76 + import { MemoryAdapter } from "@slices/session"; 77 + 78 + const adapter = new MemoryAdapter(); 79 + ``` 80 + 81 + #### SQLite Adapter (Production Single Instance) 82 + 83 + ```typescript 84 + import { SQLiteAdapter } from "@slices/session"; 85 + 86 + const adapter = new SQLiteAdapter("./sessions.db"); 87 + // or with URL 88 + const adapter = new SQLiteAdapter("sqlite://./sessions.db"); 89 + ``` 90 + 91 + #### PostgreSQL Adapter (Production Distributed) 92 + 93 + ```typescript 94 + import { PostgresAdapter } from "@slices/session"; 95 + 96 + const adapter = new PostgresAdapter("postgresql://user:pass@localhost/db"); 97 + 98 + // Initialize the database table 99 + await adapter.initialize(); 100 + ``` 101 + 102 + ## API Reference 103 + 104 + ### SessionStore 105 + 106 + Main session management class. 107 + 108 + ```typescript 109 + const store = new SessionStore({ 110 + adapter: SessionAdapter, 111 + cookieName?: string, // Default: "slice-session" 112 + cookieOptions?: CookieOptions, 113 + sessionTTL?: number, // Default: 30 days (ms) 114 + cleanupInterval?: number, // Default: 1 hour (ms) 115 + generateId?: () => string // Default: crypto.randomUUID() 116 + }); 117 + ``` 118 + 119 + #### Methods 120 + 121 + - `createSession(userId, handle?, data?)` - Create new session 122 + - `getSession(sessionId)` - Get session by ID 123 + - `updateSession(sessionId, updates)` - Update session data 124 + - `deleteSession(sessionId)` - Delete session 125 + - `getSessionFromRequest(request)` - Extract session from HTTP request 126 + - `getCurrentUser(request)` - Get user info from request 127 + - `createSessionCookie(sessionId)` - Create Set-Cookie header 128 + - `createLogoutCookie()` - Create logout cookie header 129 + - `cleanup()` - Remove expired sessions 130 + 131 + ### OAuthSessionManager 132 + 133 + OAuth-enabled session management. 134 + 135 + ```typescript 136 + const manager = withOAuthSession(sessionStore, oauthClient, { 137 + autoRefresh: true, // Auto-refresh expired tokens 138 + onTokenRefresh: async (sessionId, tokens) => { 139 + // Handle token refresh 140 + }, 141 + onLogout: async (sessionId) => { 142 + // Handle logout 143 + } 144 + }); 145 + ``` 146 + 147 + #### Methods 148 + 149 + - `createOAuthSession(request)` - Create session with OAuth tokens 150 + - `getOAuthSession(sessionId)` - Get session with token refresh 151 + - `logout(sessionId)` - OAuth logout and session cleanup 152 + - `hasValidOAuthTokens(sessionId)` - Check token validity 153 + - `getAccessToken(sessionId)` - Get access token for API calls 154 + 155 + ### Session Data Structure 156 + 157 + ```typescript 158 + interface SessionData { 159 + sessionId: string; 160 + userId: string; // User's DID or ID 161 + handle?: string; // User's handle (e.g., alice.bsky.social) 162 + isAuthenticated: boolean; 163 + data?: Record<string, unknown>; // Custom session data 164 + createdAt: number; // Timestamp 165 + expiresAt: number; // Timestamp 166 + lastAccessedAt: number; // Timestamp 167 + } 168 + ``` 169 + 170 + ## Framework Integration 171 + 172 + ### Deno Fresh 173 + 174 + ```typescript 175 + // routes/_middleware.ts 176 + import { SessionStore, SQLiteAdapter } from "@slices/session"; 177 + 178 + const sessionStore = new SessionStore({ 179 + adapter: new SQLiteAdapter("./sessions.db") 180 + }); 181 + 182 + export async function handler(req: Request, ctx: FreshContext) { 183 + const user = await sessionStore.getCurrentUser(req); 184 + ctx.state.user = user; 185 + return ctx.next(); 186 + } 187 + ``` 188 + 189 + ### Hono 190 + 191 + ```typescript 192 + import { Hono } from "hono"; 193 + import { SessionStore, SQLiteAdapter } from "@slices/session"; 194 + 195 + const app = new Hono(); 196 + const sessionStore = new SessionStore({ 197 + adapter: new SQLiteAdapter("./sessions.db") 198 + }); 199 + 200 + app.use("*", async (c, next) => { 201 + const user = await sessionStore.getCurrentUser(c.req.raw); 202 + c.set("user", user); 203 + await next(); 204 + }); 205 + ``` 206 + 207 + ## Security 208 + 209 + - Sessions are stored with secure, httpOnly cookies by default 210 + - Automatic cleanup of expired sessions 211 + - CSRF protection through SameSite cookies 212 + - Secure session ID generation using crypto.randomUUID() 213 + - Optional token refresh to keep OAuth sessions valid 214 + 215 + ## License 216 + 217 + MIT
+14
packages/session/deno.json
··· 1 + { 2 + "name": "@slices/session", 3 + "version": "0.2.1", 4 + "exports": "./mod.ts", 5 + "tasks": { 6 + "test": "deno test", 7 + "check": "deno check mod.ts" 8 + }, 9 + "imports": { 10 + "@slices/oauth": "jsr:@slices/oauth@^0.4.1", 11 + "pg": "npm:pg@^8.16.3" 12 + }, 13 + "nodeModulesDir": "auto" 14 + }
+99
packages/session/deno.lock
··· 1 + { 2 + "version": "5", 3 + "specifiers": { 4 + "jsr:@slices/oauth@~0.3.2": "0.3.2", 5 + "npm:@types/node@*": "22.15.15", 6 + "npm:pg@^8.11.0": "8.16.3", 7 + "npm:pg@^8.16.3": "8.16.3" 8 + }, 9 + "jsr": { 10 + "@slices/oauth@0.3.2": { 11 + "integrity": "51feaa6be538a61a3278ee7f1d264ed937187d09da2be1f0a2a837128df82526" 12 + } 13 + }, 14 + "npm": { 15 + "@types/node@22.15.15": { 16 + "integrity": "sha512-R5muMcZob3/Jjchn5LcO8jdKwSCbzqmPB6ruBxMcf9kbxtniZHP327s6C37iOfuw8mbKK3cAQa7sEl7afLrQ8A==", 17 + "dependencies": [ 18 + "undici-types" 19 + ] 20 + }, 21 + "pg-cloudflare@1.2.7": { 22 + "integrity": "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==" 23 + }, 24 + "pg-connection-string@2.9.1": { 25 + "integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==" 26 + }, 27 + "pg-int8@1.0.1": { 28 + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==" 29 + }, 30 + "pg-pool@3.10.1_pg@8.16.3": { 31 + "integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==", 32 + "dependencies": [ 33 + "pg" 34 + ] 35 + }, 36 + "pg-protocol@1.10.3": { 37 + "integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==" 38 + }, 39 + "pg-types@2.2.0": { 40 + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", 41 + "dependencies": [ 42 + "pg-int8", 43 + "postgres-array", 44 + "postgres-bytea", 45 + "postgres-date", 46 + "postgres-interval" 47 + ] 48 + }, 49 + "pg@8.16.3": { 50 + "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", 51 + "dependencies": [ 52 + "pg-connection-string", 53 + "pg-pool", 54 + "pg-protocol", 55 + "pg-types", 56 + "pgpass" 57 + ], 58 + "optionalDependencies": [ 59 + "pg-cloudflare" 60 + ] 61 + }, 62 + "pgpass@1.0.5": { 63 + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", 64 + "dependencies": [ 65 + "split2" 66 + ] 67 + }, 68 + "postgres-array@2.0.0": { 69 + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==" 70 + }, 71 + "postgres-bytea@1.0.0": { 72 + "integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==" 73 + }, 74 + "postgres-date@1.0.7": { 75 + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==" 76 + }, 77 + "postgres-interval@1.2.0": { 78 + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", 79 + "dependencies": [ 80 + "xtend" 81 + ] 82 + }, 83 + "split2@4.2.0": { 84 + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==" 85 + }, 86 + "undici-types@6.21.0": { 87 + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" 88 + }, 89 + "xtend@4.0.2": { 90 + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==" 91 + } 92 + }, 93 + "workspace": { 94 + "dependencies": [ 95 + "jsr:@slices/oauth@~0.3.2", 96 + "npm:pg@^8.16.3" 97 + ] 98 + } 99 + }
+14
packages/session/mod.ts
··· 1 + export { SessionStore } from "./src/store.ts"; 2 + export { MemoryAdapter } from "./src/adapters/memory.ts"; 3 + export { SQLiteAdapter } from "./src/adapters/sqlite.ts"; 4 + export { PostgresAdapter } from "./src/adapters/postgres.ts"; 5 + export { DenoKVAdapter } from "./src/adapters/deno-kv.ts"; 6 + export { withOAuthSession } from "./src/oauth-integration.ts"; 7 + export { parseCookie, serializeCookie } from "./src/cookie.ts"; 8 + export type { 9 + SessionData, 10 + SessionOptions, 11 + SessionAdapter, 12 + CookieOptions, 13 + SessionUser, 14 + } from "./src/types.ts";
+98
packages/session/src/adapters/deno-kv.ts
··· 1 + import type { SessionAdapter, SessionData } from "../types.ts"; 2 + 3 + export class DenoKVAdapter implements SessionAdapter { 4 + private kv: Deno.Kv; 5 + private keyPrefix: string; 6 + 7 + constructor(kv: Deno.Kv, keyPrefix = "sessions") { 8 + this.kv = kv; 9 + this.keyPrefix = keyPrefix; 10 + } 11 + 12 + static async create(path?: string, keyPrefix = "sessions"): Promise<DenoKVAdapter> { 13 + const kv = await Deno.openKv(path); 14 + return new DenoKVAdapter(kv, keyPrefix); 15 + } 16 + 17 + private getKey(sessionId: string): string[] { 18 + return [this.keyPrefix, sessionId]; 19 + } 20 + 21 + async get(sessionId: string): Promise<SessionData | null> { 22 + const result = await this.kv.get<SessionData>(this.getKey(sessionId)); 23 + return result.value; 24 + } 25 + 26 + async set(sessionId: string, data: SessionData): Promise<void> { 27 + await this.kv.set(this.getKey(sessionId), data); 28 + } 29 + 30 + async update(sessionId: string, updates: Partial<SessionData>): Promise<boolean> { 31 + const existing = await this.get(sessionId); 32 + if (!existing) return false; 33 + 34 + const updated = { ...existing, ...updates }; 35 + await this.set(sessionId, updated); 36 + return true; 37 + } 38 + 39 + async delete(sessionId: string): Promise<void> { 40 + await this.kv.delete(this.getKey(sessionId)); 41 + } 42 + 43 + async cleanup(expiresBeforeMs: number): Promise<number> { 44 + let cleanedCount = 0; 45 + 46 + const iter = this.kv.list<SessionData>({ prefix: [this.keyPrefix] }); 47 + 48 + for await (const entry of iter) { 49 + if (entry.value && entry.value.expiresAt < expiresBeforeMs) { 50 + await this.kv.delete(entry.key); 51 + cleanedCount++; 52 + } 53 + } 54 + 55 + return cleanedCount; 56 + } 57 + 58 + async exists(sessionId: string): Promise<boolean> { 59 + const result = await this.kv.get(this.getKey(sessionId)); 60 + return result.value !== null; 61 + } 62 + 63 + close(): void { 64 + this.kv.close(); 65 + } 66 + 67 + async getSessionsByUser(userId: string): Promise<SessionData[]> { 68 + const sessions: SessionData[] = []; 69 + const iter = this.kv.list<SessionData>({ prefix: [this.keyPrefix] }); 70 + 71 + for await (const entry of iter) { 72 + if (entry.value && entry.value.userId === userId) { 73 + sessions.push(entry.value); 74 + } 75 + } 76 + 77 + return sessions; 78 + } 79 + 80 + async clear(): Promise<void> { 81 + const iter = this.kv.list({ prefix: [this.keyPrefix] }); 82 + 83 + for await (const entry of iter) { 84 + await this.kv.delete(entry.key); 85 + } 86 + } 87 + 88 + async count(): Promise<number> { 89 + let count = 0; 90 + const iter = this.kv.list({ prefix: [this.keyPrefix] }); 91 + 92 + for await (const _entry of iter) { 93 + count++; 94 + } 95 + 96 + return count; 97 + } 98 + }
+57
packages/session/src/adapters/memory.ts
··· 1 + import type { SessionAdapter, SessionData } from "../types.ts"; 2 + 3 + export class MemoryAdapter implements SessionAdapter { 4 + private sessions = new Map<string, SessionData>(); 5 + 6 + get(sessionId: string): Promise<SessionData | null> { 7 + return Promise.resolve(this.sessions.get(sessionId) ?? null); 8 + } 9 + 10 + set(sessionId: string, data: SessionData): Promise<void> { 11 + this.sessions.set(sessionId, data); 12 + return Promise.resolve(); 13 + } 14 + 15 + update(sessionId: string, updates: Partial<SessionData>): Promise<boolean> { 16 + const existing = this.sessions.get(sessionId); 17 + if (!existing) return Promise.resolve(false); 18 + 19 + this.sessions.set(sessionId, { ...existing, ...updates }); 20 + return Promise.resolve(true); 21 + } 22 + 23 + delete(sessionId: string): Promise<void> { 24 + this.sessions.delete(sessionId); 25 + return Promise.resolve(); 26 + } 27 + 28 + cleanup(expiresBeforeMs: number): Promise<number> { 29 + let cleanedCount = 0; 30 + 31 + for (const [sessionId, data] of this.sessions.entries()) { 32 + if (data.expiresAt < expiresBeforeMs) { 33 + this.sessions.delete(sessionId); 34 + cleanedCount++; 35 + } 36 + } 37 + 38 + return Promise.resolve(cleanedCount); 39 + } 40 + 41 + exists(sessionId: string): Promise<boolean> { 42 + return Promise.resolve(this.sessions.has(sessionId)); 43 + } 44 + 45 + // Memory-specific methods 46 + clear(): void { 47 + this.sessions.clear(); 48 + } 49 + 50 + size(): number { 51 + return this.sessions.size; 52 + } 53 + 54 + keys(): string[] { 55 + return Array.from(this.sessions.keys()); 56 + } 57 + }
+266
packages/session/src/adapters/postgres.ts
··· 1 + import type { SessionAdapter, SessionData } from "../types.ts"; 2 + import { Client } from "pg"; 3 + 4 + interface SessionTable { 5 + session_id: string; 6 + user_id: string; 7 + handle: string | null; 8 + is_authenticated: boolean; 9 + data: string | null; 10 + created_at: Date; 11 + expires_at: Date; 12 + last_accessed_at: Date; 13 + } 14 + 15 + export class PostgresAdapter implements SessionAdapter { 16 + private connectionString: string; 17 + 18 + constructor(connectionString: string) { 19 + this.connectionString = connectionString; 20 + } 21 + 22 + // Initialize the sessions table 23 + async initialize(): Promise<void> { 24 + const client = new Client({ connectionString: this.connectionString }); 25 + 26 + try { 27 + await client.connect(); 28 + 29 + await client.query(` 30 + CREATE TABLE IF NOT EXISTS sessions ( 31 + session_id TEXT PRIMARY KEY, 32 + user_id TEXT NOT NULL, 33 + handle TEXT, 34 + is_authenticated BOOLEAN NOT NULL DEFAULT true, 35 + data JSONB, 36 + created_at BIGINT NOT NULL, 37 + expires_at BIGINT NOT NULL, 38 + last_accessed_at BIGINT NOT NULL 39 + ) 40 + `); 41 + 42 + // Index for cleanup operations 43 + await client.query(` 44 + CREATE INDEX IF NOT EXISTS idx_sessions_expires_at 45 + ON sessions(expires_at) 46 + `); 47 + 48 + // Index for user lookups 49 + await client.query(` 50 + CREATE INDEX IF NOT EXISTS idx_sessions_user_id 51 + ON sessions(user_id) 52 + `); 53 + } finally { 54 + await client.end(); 55 + } 56 + } 57 + 58 + async get(sessionId: string): Promise<SessionData | null> { 59 + const client = new Client({ connectionString: this.connectionString }); 60 + 61 + try { 62 + await client.connect(); 63 + 64 + const result = await client.query( 65 + "SELECT * FROM sessions WHERE session_id = $1", 66 + [sessionId] 67 + ); 68 + 69 + if (result.rows.length === 0) return null; 70 + 71 + return this.rowToSessionData(result.rows[0]); 72 + } finally { 73 + await client.end(); 74 + } 75 + } 76 + 77 + async set(sessionId: string, data: SessionData): Promise<void> { 78 + const client = new Client({ connectionString: this.connectionString }); 79 + 80 + try { 81 + await client.connect(); 82 + 83 + await client.query( 84 + ` 85 + INSERT INTO sessions 86 + (session_id, user_id, handle, is_authenticated, data, created_at, expires_at, last_accessed_at) 87 + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) 88 + ON CONFLICT (session_id) 89 + DO UPDATE SET 90 + user_id = $2, 91 + handle = $3, 92 + is_authenticated = $4, 93 + data = $5, 94 + created_at = $6, 95 + expires_at = $7, 96 + last_accessed_at = $8 97 + `, 98 + [ 99 + sessionId, 100 + data.userId, 101 + data.handle || null, 102 + data.isAuthenticated, 103 + data.data ? JSON.stringify(data.data) : null, 104 + data.createdAt, 105 + data.expiresAt, 106 + data.lastAccessedAt, 107 + ] 108 + ); 109 + } finally { 110 + await client.end(); 111 + } 112 + } 113 + 114 + async update( 115 + sessionId: string, 116 + updates: Partial<SessionData> 117 + ): Promise<boolean> { 118 + const setParts: string[] = []; 119 + const values: (string | number | boolean | null)[] = []; 120 + let paramIndex = 1; 121 + 122 + if (updates.userId !== undefined) { 123 + setParts.push(`user_id = $${paramIndex++}`); 124 + values.push(updates.userId); 125 + } 126 + 127 + if (updates.handle !== undefined) { 128 + setParts.push(`handle = $${paramIndex++}`); 129 + values.push(updates.handle); 130 + } 131 + 132 + if (updates.isAuthenticated !== undefined) { 133 + setParts.push(`is_authenticated = $${paramIndex++}`); 134 + values.push(updates.isAuthenticated); 135 + } 136 + 137 + if (updates.data !== undefined) { 138 + setParts.push(`data = $${paramIndex++}`); 139 + values.push(updates.data ? JSON.stringify(updates.data) : null); 140 + } 141 + 142 + if (updates.expiresAt !== undefined) { 143 + setParts.push(`expires_at = $${paramIndex++}`); 144 + values.push(updates.expiresAt); 145 + } 146 + 147 + if (updates.lastAccessedAt !== undefined) { 148 + setParts.push(`last_accessed_at = $${paramIndex++}`); 149 + values.push(updates.lastAccessedAt); 150 + } 151 + 152 + if (setParts.length === 0) return false; 153 + 154 + values.push(sessionId); 155 + 156 + const client = new Client({ connectionString: this.connectionString }); 157 + 158 + try { 159 + await client.connect(); 160 + 161 + const result = await client.query( 162 + ` 163 + UPDATE sessions 164 + SET ${setParts.join(", ")} 165 + WHERE session_id = $${paramIndex} 166 + `, 167 + values 168 + ); 169 + 170 + return result.rowCount !== null && result.rowCount > 0; 171 + } finally { 172 + await client.end(); 173 + } 174 + } 175 + 176 + async delete(sessionId: string): Promise<void> { 177 + const client = new Client({ connectionString: this.connectionString }); 178 + 179 + try { 180 + await client.connect(); 181 + await client.query("DELETE FROM sessions WHERE session_id = $1", [ 182 + sessionId, 183 + ]); 184 + } finally { 185 + await client.end(); 186 + } 187 + } 188 + 189 + async cleanup(expiresBeforeMs: number): Promise<number> { 190 + const client = new Client({ connectionString: this.connectionString }); 191 + 192 + try { 193 + await client.connect(); 194 + 195 + const result = await client.query( 196 + "DELETE FROM sessions WHERE expires_at < $1", 197 + [expiresBeforeMs] 198 + ); 199 + 200 + return result.rowCount || 0; 201 + } finally { 202 + await client.end(); 203 + } 204 + } 205 + 206 + async exists(sessionId: string): Promise<boolean> { 207 + const client = new Client({ connectionString: this.connectionString }); 208 + 209 + try { 210 + await client.connect(); 211 + 212 + const result = await client.query( 213 + "SELECT 1 FROM sessions WHERE session_id = $1 LIMIT 1", 214 + [sessionId] 215 + ); 216 + 217 + return result.rows.length > 0; 218 + } finally { 219 + await client.end(); 220 + } 221 + } 222 + 223 + private rowToSessionData(row: SessionTable): SessionData { 224 + return { 225 + sessionId: row.session_id, 226 + userId: row.user_id, 227 + handle: row.handle || undefined, 228 + isAuthenticated: row.is_authenticated, 229 + data: row.data ? JSON.parse(row.data) : undefined, 230 + createdAt: row.created_at.getTime(), 231 + expiresAt: row.expires_at.getTime(), 232 + lastAccessedAt: row.last_accessed_at.getTime(), 233 + }; 234 + } 235 + 236 + // PostgreSQL-specific methods 237 + async getSessionsByUser(userId: string): Promise<SessionData[]> { 238 + const client = new Client({ connectionString: this.connectionString }); 239 + 240 + try { 241 + await client.connect(); 242 + 243 + const result = await client.query( 244 + "SELECT * FROM sessions WHERE user_id = $1", 245 + [userId] 246 + ); 247 + 248 + return result.rows.map((row: Record<string, unknown>) => 249 + this.rowToSessionData(row as unknown as SessionTable) 250 + ); 251 + } finally { 252 + await client.end(); 253 + } 254 + } 255 + 256 + async vacuum(): Promise<void> { 257 + const client = new Client({ connectionString: this.connectionString }); 258 + 259 + try { 260 + await client.connect(); 261 + await client.query("VACUUM ANALYZE sessions"); 262 + } finally { 263 + await client.end(); 264 + } 265 + } 266 + }
+181
packages/session/src/adapters/sqlite.ts
··· 1 + import type { SessionAdapter, SessionData } from "../types.ts"; 2 + import { DatabaseSync } from "node:sqlite"; 3 + 4 + interface SessionTable { 5 + session_id: string; 6 + user_id: string; 7 + handle: string | null; 8 + is_authenticated: number; 9 + data: string | null; 10 + created_at: number; 11 + expires_at: number; 12 + last_accessed_at: number; 13 + } 14 + 15 + export class SQLiteAdapter implements SessionAdapter { 16 + private db: DatabaseSync; 17 + 18 + constructor(databasePath: string) { 19 + // Handle sqlite:// URLs or direct paths 20 + const dbPath = databasePath.startsWith("sqlite://") 21 + ? databasePath.slice(9) 22 + : databasePath; 23 + 24 + this.db = new DatabaseSync(dbPath); 25 + this.initializeDatabase(); 26 + } 27 + 28 + private initializeDatabase() { 29 + this.db.exec(` 30 + CREATE TABLE IF NOT EXISTS sessions ( 31 + session_id TEXT PRIMARY KEY, 32 + user_id TEXT NOT NULL, 33 + handle TEXT, 34 + is_authenticated INTEGER NOT NULL DEFAULT 1, 35 + data TEXT, -- JSON string 36 + created_at INTEGER NOT NULL, 37 + expires_at INTEGER NOT NULL, 38 + last_accessed_at INTEGER NOT NULL 39 + ) 40 + `); 41 + 42 + // Index for cleanup operations 43 + this.db.exec(` 44 + CREATE INDEX IF NOT EXISTS idx_sessions_expires_at 45 + ON sessions(expires_at) 46 + `); 47 + 48 + // Index for user lookups 49 + this.db.exec(` 50 + CREATE INDEX IF NOT EXISTS idx_sessions_user_id 51 + ON sessions(user_id) 52 + `); 53 + } 54 + 55 + get(sessionId: string): Promise<SessionData | null> { 56 + const stmt = this.db.prepare(` 57 + SELECT * FROM sessions 58 + WHERE session_id = ? 59 + `); 60 + 61 + const row = stmt.get(sessionId) as SessionTable | undefined; 62 + if (!row) return Promise.resolve(null); 63 + 64 + return Promise.resolve(this.rowToSessionData(row)); 65 + } 66 + 67 + set(sessionId: string, data: SessionData): Promise<void> { 68 + const stmt = this.db.prepare(` 69 + INSERT OR REPLACE INTO sessions 70 + (session_id, user_id, handle, is_authenticated, data, created_at, expires_at, last_accessed_at) 71 + VALUES (?, ?, ?, ?, ?, ?, ?, ?) 72 + `); 73 + 74 + stmt.run( 75 + sessionId, 76 + data.userId, 77 + data.handle || null, 78 + data.isAuthenticated ? 1 : 0, 79 + data.data ? JSON.stringify(data.data) : null, 80 + data.createdAt, 81 + data.expiresAt, 82 + data.lastAccessedAt 83 + ); 84 + return Promise.resolve(); 85 + } 86 + 87 + update(sessionId: string, updates: Partial<SessionData>): Promise<boolean> { 88 + const setParts: string[] = []; 89 + const values: (string | number | null)[] = []; 90 + 91 + if (updates.userId !== undefined) { 92 + setParts.push("user_id = ?"); 93 + values.push(updates.userId); 94 + } 95 + 96 + if (updates.handle !== undefined) { 97 + setParts.push("handle = ?"); 98 + values.push(updates.handle); 99 + } 100 + 101 + if (updates.isAuthenticated !== undefined) { 102 + setParts.push("is_authenticated = ?"); 103 + values.push(updates.isAuthenticated ? 1 : 0); 104 + } 105 + 106 + if (updates.data !== undefined) { 107 + setParts.push("data = ?"); 108 + values.push(updates.data ? JSON.stringify(updates.data) : null); 109 + } 110 + 111 + if (updates.expiresAt !== undefined) { 112 + setParts.push("expires_at = ?"); 113 + values.push(updates.expiresAt); 114 + } 115 + 116 + if (updates.lastAccessedAt !== undefined) { 117 + setParts.push("last_accessed_at = ?"); 118 + values.push(updates.lastAccessedAt); 119 + } 120 + 121 + if (setParts.length === 0) return Promise.resolve(false); 122 + 123 + values.push(sessionId); 124 + 125 + const stmt = this.db.prepare(` 126 + UPDATE sessions 127 + SET ${setParts.join(", ")} 128 + WHERE session_id = ? 129 + `); 130 + 131 + const result = stmt.run(...values); 132 + return Promise.resolve(Number(result.changes) > 0); 133 + } 134 + 135 + delete(sessionId: string): Promise<void> { 136 + const stmt = this.db.prepare("DELETE FROM sessions WHERE session_id = ?"); 137 + stmt.run(sessionId); 138 + return Promise.resolve(); 139 + } 140 + 141 + cleanup(expiresBeforeMs: number): Promise<number> { 142 + const stmt = this.db.prepare("DELETE FROM sessions WHERE expires_at < ?"); 143 + const result = stmt.run(expiresBeforeMs); 144 + return Promise.resolve(Number(result.changes)); 145 + } 146 + 147 + exists(sessionId: string): Promise<boolean> { 148 + const stmt = this.db.prepare( 149 + "SELECT 1 FROM sessions WHERE session_id = ? LIMIT 1" 150 + ); 151 + return Promise.resolve(stmt.get(sessionId) !== undefined); 152 + } 153 + 154 + private rowToSessionData(row: SessionTable): SessionData { 155 + return { 156 + sessionId: row.session_id, 157 + userId: row.user_id, 158 + handle: row.handle || undefined, 159 + isAuthenticated: Boolean(row.is_authenticated), 160 + data: row.data ? JSON.parse(row.data) : undefined, 161 + createdAt: row.created_at, 162 + expiresAt: row.expires_at, 163 + lastAccessedAt: row.last_accessed_at, 164 + }; 165 + } 166 + 167 + // SQLite-specific methods 168 + close(): void { 169 + this.db.close(); 170 + } 171 + 172 + vacuum(): void { 173 + this.db.exec("VACUUM"); 174 + } 175 + 176 + getSessionsByUser(userId: string): SessionData[] { 177 + const stmt = this.db.prepare("SELECT * FROM sessions WHERE user_id = ?"); 178 + const rows = stmt.all(userId) as unknown as SessionTable[]; 179 + return rows.map(row => this.rowToSessionData(row)); 180 + } 181 + }
+55
packages/session/src/cookie.ts
··· 1 + import type { CookieOptions } from "./types.ts"; 2 + 3 + export function parseCookie(cookieString: string): Record<string, string> { 4 + const cookies: Record<string, string> = {}; 5 + 6 + if (!cookieString) return cookies; 7 + 8 + const pairs = cookieString.split(";"); 9 + 10 + for (const pair of pairs) { 11 + const [key, ...valueParts] = pair.split("="); 12 + const trimmedKey = key?.trim(); 13 + const value = valueParts.join("=")?.trim(); 14 + 15 + if (trimmedKey) { 16 + cookies[trimmedKey] = decodeURIComponent(value || ""); 17 + } 18 + } 19 + 20 + return cookies; 21 + } 22 + 23 + export function serializeCookie( 24 + name: string, 25 + value: string, 26 + options: CookieOptions = {} 27 + ): string { 28 + const parts = [`${encodeURIComponent(name)}=${encodeURIComponent(value)}`]; 29 + 30 + if (options.maxAge !== undefined) { 31 + parts.push(`Max-Age=${options.maxAge}`); 32 + } 33 + 34 + if (options.domain) { 35 + parts.push(`Domain=${options.domain}`); 36 + } 37 + 38 + if (options.path) { 39 + parts.push(`Path=${options.path}`); 40 + } 41 + 42 + if (options.httpOnly) { 43 + parts.push("HttpOnly"); 44 + } 45 + 46 + if (options.secure) { 47 + parts.push("Secure"); 48 + } 49 + 50 + if (options.sameSite) { 51 + parts.push(`SameSite=${options.sameSite}`); 52 + } 53 + 54 + return parts.join("; "); 55 + }
+247
packages/session/src/oauth-integration.ts
··· 1 + import type { SessionStore } from "./store.ts"; 2 + import type { SessionData } from "./types.ts"; 3 + import type { OAuthClient, OAuthTokens } from "@slices/oauth"; 4 + 5 + export interface OAuthSessionOptions { 6 + sessionStore: SessionStore; 7 + oauthClient: OAuthClient; 8 + autoRefresh?: boolean; 9 + onTokenRefresh?: (sessionId: string, tokens: OAuthTokens) => Promise<void>; 10 + onLogout?: (sessionId: string) => Promise<void>; 11 + } 12 + 13 + export class OAuthSessionManager { 14 + private options: OAuthSessionOptions; 15 + 16 + constructor(options: OAuthSessionOptions) { 17 + this.options = options; 18 + } 19 + 20 + // Create a session linked to OAuth (no token duplication) 21 + async createOAuthSession(): Promise<string | null> { 22 + try { 23 + // Verify OAuth tokens exist (but don't store them) 24 + const tokens = await this.options.oauthClient.ensureValidToken(); 25 + if (!tokens.accessToken) { 26 + return null; 27 + } 28 + 29 + // Get user info from OAuth 30 + const userInfo = await this.options.oauthClient.getUserInfo(); 31 + if (!userInfo) { 32 + return null; 33 + } 34 + 35 + // Create session with user data only (no token storage) 36 + const sessionId = await this.options.sessionStore.createSession( 37 + userInfo.sub, 38 + userInfo.name, 39 + { 40 + userInfo: { 41 + ...userInfo, 42 + handle: userInfo.name, 43 + }, 44 + // OAuth tokens are managed separately by @slices/oauth 45 + // No token duplication here 46 + } 47 + ); 48 + 49 + return sessionId; 50 + } catch (error) { 51 + console.error("Failed to create OAuth session:", error); 52 + return null; 53 + } 54 + } 55 + 56 + // Get session (tokens managed separately by OAuth client) 57 + async getOAuthSession(sessionId: string): Promise<SessionData | null> { 58 + const session = await this.options.sessionStore.getSession(sessionId); 59 + if (!session) { 60 + return null; 61 + } 62 + 63 + // Auto-refresh is handled by OAuth client, not session storage 64 + if (this.options.autoRefresh) { 65 + try { 66 + // This ensures tokens are fresh in OAuth storage 67 + await this.options.oauthClient.ensureValidToken(); 68 + 69 + // Call refresh callback if provided 70 + if (this.options.onTokenRefresh) { 71 + const tokens = await this.options.oauthClient.ensureValidToken(); 72 + await this.options.onTokenRefresh(sessionId, tokens); 73 + } 74 + } catch (error) { 75 + console.error("Failed to refresh OAuth tokens:", error); 76 + // Session is still valid even if token refresh fails 77 + } 78 + } 79 + 80 + return session; 81 + } 82 + 83 + // Logout and cleanup OAuth session 84 + async logout(sessionId: string): Promise<void> { 85 + try { 86 + // Call OAuth logout 87 + await this.options.oauthClient.logout(); 88 + 89 + // Delete session 90 + await this.options.sessionStore.deleteSession(sessionId); 91 + 92 + // Call logout callback if provided 93 + if (this.options.onLogout) { 94 + await this.options.onLogout(sessionId); 95 + } 96 + } catch (error) { 97 + console.error("Failed to logout OAuth session:", error); 98 + // Still delete the session even if OAuth logout fails 99 + await this.options.sessionStore.deleteSession(sessionId); 100 + } 101 + } 102 + 103 + // Check if session has valid OAuth tokens (via OAuth client) 104 + async hasValidOAuthTokens(sessionId: string): Promise<boolean> { 105 + const session = await this.getOAuthSession(sessionId); 106 + if (!session) return false; 107 + 108 + try { 109 + // Let OAuth client determine token validity 110 + const tokens = await this.options.oauthClient.ensureValidToken(); 111 + return !!tokens.accessToken; 112 + } catch (_error) { 113 + return false; 114 + } 115 + } 116 + 117 + // Get OAuth access token for API calls (from OAuth client, not session) 118 + async getAccessToken(sessionId: string): Promise<string | null> { 119 + const session = await this.getOAuthSession(sessionId); 120 + if (!session) return null; 121 + 122 + try { 123 + // Get fresh tokens from OAuth client 124 + const tokens = await this.options.oauthClient.ensureValidToken(); 125 + return tokens.accessToken || null; 126 + } catch (_error) { 127 + return null; 128 + } 129 + } 130 + } 131 + 132 + // Convenience function to create an OAuth-enabled session store 133 + export function withOAuthSession( 134 + sessionStore: SessionStore, 135 + oauthClient: OAuthClient, 136 + options: Partial<OAuthSessionOptions> = {} 137 + ): OAuthSessionManager { 138 + return new OAuthSessionManager({ 139 + sessionStore, 140 + oauthClient, 141 + autoRefresh: true, 142 + ...options, 143 + }); 144 + } 145 + 146 + // Helper middleware for frameworks 147 + export interface OAuthSessionMiddlewareOptions extends OAuthSessionOptions { 148 + cookieName?: string; 149 + loginRedirect?: string; 150 + logoutRedirect?: string; 151 + } 152 + 153 + export function createOAuthSessionMiddleware( 154 + options: OAuthSessionMiddlewareOptions 155 + ) { 156 + const manager = new OAuthSessionManager(options); 157 + const cookieName = options.cookieName || "slice-session"; 158 + 159 + return { 160 + // Get current user from session 161 + async getCurrentUser(request: Request) { 162 + const sessionId = getSessionIdFromRequest(request, cookieName); 163 + if (!sessionId) { 164 + return { isAuthenticated: false }; 165 + } 166 + 167 + const session = await manager.getOAuthSession(sessionId); 168 + if (!session) { 169 + return { isAuthenticated: false }; 170 + } 171 + 172 + return { 173 + isAuthenticated: true, 174 + sub: session.userId, 175 + handle: session.handle, 176 + // User info from session data (no OAuth tokens) 177 + ...(session.data?.userInfo || {}), 178 + }; 179 + }, 180 + 181 + // Login handler 182 + async login(): Promise<Response> { 183 + const sessionId = await manager.createOAuthSession(); 184 + if (!sessionId) { 185 + return new Response("Authentication failed", { status: 401 }); 186 + } 187 + 188 + const cookie = options.sessionStore.createSessionCookie(sessionId); 189 + const redirectUrl = options.loginRedirect || "/"; 190 + 191 + return new Response("", { 192 + status: 302, 193 + headers: { 194 + Location: redirectUrl, 195 + "Set-Cookie": cookie, 196 + }, 197 + }); 198 + }, 199 + 200 + // Logout handler 201 + async logout(request: Request): Promise<Response> { 202 + const sessionId = getSessionIdFromRequest(request, cookieName); 203 + if (sessionId) { 204 + await manager.logout(sessionId); 205 + } 206 + 207 + const cookie = options.sessionStore.createLogoutCookie(); 208 + const redirectUrl = options.logoutRedirect || "/"; 209 + 210 + return new Response("", { 211 + status: 302, 212 + headers: { 213 + Location: redirectUrl, 214 + "Set-Cookie": cookie, 215 + }, 216 + }); 217 + }, 218 + 219 + manager, 220 + }; 221 + } 222 + 223 + // Helper to extract session ID from request 224 + function getSessionIdFromRequest( 225 + request: Request, 226 + cookieName: string 227 + ): string | null { 228 + const cookieHeader = request.headers.get("cookie"); 229 + if (!cookieHeader) return null; 230 + 231 + const cookies = parseCookies(cookieHeader); 232 + return cookies[cookieName] || null; 233 + } 234 + 235 + // Simple cookie parser 236 + function parseCookies(cookieString: string): Record<string, string> { 237 + const cookies: Record<string, string> = {}; 238 + 239 + cookieString.split(";").forEach((cookie) => { 240 + const [key, value] = cookie.split("=").map((s) => s.trim()); 241 + if (key && value) { 242 + cookies[decodeURIComponent(key)] = decodeURIComponent(value); 243 + } 244 + }); 245 + 246 + return cookies; 247 + }
+158
packages/session/src/store.ts
··· 1 + import type { SessionAdapter, SessionData, SessionOptions, SessionUser } from "./types.ts"; 2 + import { parseCookie, serializeCookie } from "./cookie.ts"; 3 + 4 + const DEFAULT_SESSION_TTL = 30 * 24 * 60 * 60 * 1000; // 30 days 5 + const DEFAULT_CLEANUP_INTERVAL = 60 * 60 * 1000; // 1 hour 6 + const DEFAULT_COOKIE_NAME = "slice-session"; 7 + 8 + export class SessionStore { 9 + private adapter: SessionAdapter; 10 + private options: Required<SessionOptions>; 11 + private cleanupTimer?: number; 12 + 13 + constructor(options: SessionOptions) { 14 + this.adapter = options.adapter; 15 + this.options = { 16 + adapter: options.adapter, 17 + cookieName: options.cookieName ?? DEFAULT_COOKIE_NAME, 18 + cookieOptions: { 19 + httpOnly: true, 20 + secure: true, 21 + sameSite: "lax", 22 + path: "/", 23 + ...options.cookieOptions, 24 + }, 25 + sessionTTL: options.sessionTTL ?? DEFAULT_SESSION_TTL, 26 + cleanupInterval: options.cleanupInterval ?? DEFAULT_CLEANUP_INTERVAL, 27 + generateId: options.generateId ?? (() => crypto.randomUUID()), 28 + }; 29 + 30 + // Start cleanup timer 31 + this.startCleanupTimer(); 32 + } 33 + 34 + private startCleanupTimer() { 35 + if (this.cleanupTimer) { 36 + clearInterval(this.cleanupTimer); 37 + } 38 + 39 + this.cleanupTimer = setInterval(() => { 40 + this.cleanup().catch(console.error); 41 + }, this.options.cleanupInterval); 42 + } 43 + 44 + async createSession(userId: string, handle?: string, data?: Record<string, unknown>): Promise<string> { 45 + const sessionId = this.options.generateId(); 46 + const now = Date.now(); 47 + 48 + const sessionData: SessionData = { 49 + sessionId, 50 + userId, 51 + handle, 52 + isAuthenticated: true, 53 + data, 54 + createdAt: now, 55 + expiresAt: now + this.options.sessionTTL, 56 + lastAccessedAt: now, 57 + }; 58 + 59 + await this.adapter.set(sessionId, sessionData); 60 + return sessionId; 61 + } 62 + 63 + async getSession(sessionId: string): Promise<SessionData | null> { 64 + if (!sessionId) return null; 65 + 66 + const session = await this.adapter.get(sessionId); 67 + if (!session) return null; 68 + 69 + // Check if session is expired 70 + if (session.expiresAt < Date.now()) { 71 + await this.adapter.delete(sessionId); 72 + return null; 73 + } 74 + 75 + // Update last accessed time 76 + await this.adapter.update(sessionId, { 77 + lastAccessedAt: Date.now(), 78 + }); 79 + 80 + return session; 81 + } 82 + 83 + async updateSession(sessionId: string, updates: Partial<SessionData>): Promise<boolean> { 84 + const session = await this.getSession(sessionId); 85 + if (!session) return false; 86 + 87 + // Extend expiration on update 88 + const extendedExpiration = Date.now() + this.options.sessionTTL; 89 + 90 + return await this.adapter.update(sessionId, { 91 + ...updates, 92 + expiresAt: extendedExpiration, 93 + lastAccessedAt: Date.now(), 94 + }); 95 + } 96 + 97 + async deleteSession(sessionId: string): Promise<void> { 98 + await this.adapter.delete(sessionId); 99 + } 100 + 101 + async cleanup(): Promise<number> { 102 + return await this.adapter.cleanup(Date.now()); 103 + } 104 + 105 + // Get session from request cookies 106 + async getSessionFromRequest(request: Request): Promise<SessionData | null> { 107 + const cookieHeader = request.headers.get("cookie"); 108 + if (!cookieHeader) return null; 109 + 110 + const cookies = parseCookie(cookieHeader); 111 + const sessionId = cookies[this.options.cookieName]; 112 + if (!sessionId) return null; 113 + 114 + return await this.getSession(sessionId); 115 + } 116 + 117 + // Get user info from session 118 + async getCurrentUser(request: Request): Promise<SessionUser> { 119 + const session = await this.getSessionFromRequest(request); 120 + 121 + if (!session) { 122 + return { 123 + isAuthenticated: false, 124 + }; 125 + } 126 + 127 + return { 128 + sub: session.userId, 129 + handle: session.handle, 130 + isAuthenticated: session.isAuthenticated, 131 + ...session.data, 132 + }; 133 + } 134 + 135 + // Create session cookie header 136 + createSessionCookie(sessionId: string): string { 137 + return serializeCookie(this.options.cookieName, sessionId, { 138 + ...this.options.cookieOptions, 139 + maxAge: Math.floor(this.options.sessionTTL / 1000), // Convert to seconds 140 + }); 141 + } 142 + 143 + // Create logout cookie header (clears the session) 144 + createLogoutCookie(): string { 145 + return serializeCookie(this.options.cookieName, "", { 146 + ...this.options.cookieOptions, 147 + maxAge: 0, 148 + }); 149 + } 150 + 151 + // Cleanup on destroy 152 + destroy() { 153 + if (this.cleanupTimer) { 154 + clearInterval(this.cleanupTimer); 155 + this.cleanupTimer = undefined; 156 + } 157 + } 158 + }
+44
packages/session/src/types.ts
··· 1 + export interface SessionData { 2 + sessionId: string; 3 + userId: string; 4 + handle?: string; 5 + isAuthenticated: boolean; 6 + data?: Record<string, unknown>; 7 + createdAt: number; 8 + expiresAt: number; 9 + lastAccessedAt: number; 10 + } 11 + 12 + export interface SessionUser { 13 + sub?: string; 14 + handle?: string; 15 + isAuthenticated: boolean; 16 + [key: string]: unknown; 17 + } 18 + 19 + export interface SessionOptions { 20 + adapter: SessionAdapter; 21 + cookieName?: string; 22 + cookieOptions?: CookieOptions; 23 + sessionTTL?: number; // milliseconds, default 30 days 24 + cleanupInterval?: number; // milliseconds, default 1 hour 25 + generateId?: () => string; 26 + } 27 + 28 + export interface CookieOptions { 29 + httpOnly?: boolean; 30 + secure?: boolean; 31 + sameSite?: "strict" | "lax" | "none"; 32 + domain?: string; 33 + path?: string; 34 + maxAge?: number; // seconds 35 + } 36 + 37 + export interface SessionAdapter { 38 + get(sessionId: string): Promise<SessionData | null>; 39 + set(sessionId: string, data: SessionData): Promise<void>; 40 + update(sessionId: string, data: Partial<SessionData>): Promise<boolean>; 41 + delete(sessionId: string): Promise<void>; 42 + cleanup(expiresBeforeMs: number): Promise<number>; 43 + exists(sessionId: string): Promise<boolean>; 44 + }