Framework-agnostic session management for AT Protocol applications using Iron Session encryption
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Initial release: framework-agnostic session management for AT Protocol

- SessionManager class for cookie and Bearer token sessions
- Iron Session encryption for secure cookie storage
- Mobile token seal/unseal support
- Full test coverage

Tijs Teulings f6edced4

+952
+94
README.md
··· 1 + # @tijs/atproto-sessions 2 + 3 + Framework-agnostic session management for AT Protocol applications. 4 + 5 + Provides encrypted session cookies and mobile Bearer tokens using Iron Session. 6 + Works with standard Web Request/Response APIs - no framework dependencies. 7 + 8 + ## Installation 9 + 10 + ```bash 11 + deno add jsr:@tijs/atproto-sessions 12 + ``` 13 + 14 + ## Usage 15 + 16 + ```typescript 17 + import { SessionManager } from "@tijs/atproto-sessions"; 18 + 19 + const sessions = new SessionManager({ 20 + cookieSecret: Deno.env.get("COOKIE_SECRET")!, // Min 32 chars 21 + cookieName: "sid", // Optional, default: "sid" 22 + sessionTtl: 60 * 60 * 24 * 14, // Optional, default: 7 days 23 + }); 24 + 25 + // In a request handler - extract session from cookie 26 + const { data, setCookieHeader, error } = await sessions.getSessionFromRequest( 27 + request, 28 + ); 29 + 30 + if (data) { 31 + // User is authenticated 32 + console.log("User DID:", data.did); 33 + 34 + // Set setCookieHeader on response to refresh session 35 + response.headers.set("Set-Cookie", setCookieHeader); 36 + } 37 + 38 + // Create a new session (e.g., after OAuth callback) 39 + const sessionData = { 40 + did: "did:plc:abc123", 41 + createdAt: Date.now(), 42 + lastAccessed: Date.now(), 43 + }; 44 + const setCookie = await sessions.createSession(sessionData); 45 + response.headers.set("Set-Cookie", setCookie); 46 + 47 + // Clear session (logout) 48 + response.headers.set("Set-Cookie", sessions.getClearCookieHeader()); 49 + ``` 50 + 51 + ## Mobile Bearer Tokens 52 + 53 + For mobile apps that can't use cookies: 54 + 55 + ```typescript 56 + // Seal a token for mobile client 57 + const token = await sessions.sealToken({ did: "did:plc:abc123" }); 58 + 59 + // Validate Bearer token from Authorization header 60 + const result = await sessions.validateBearerToken(`Bearer ${token}`); 61 + if (result.data) { 62 + console.log("Mobile user DID:", result.data.did); 63 + } 64 + 65 + // Refresh a mobile token 66 + const newToken = await sessions.refreshBearerToken(`Bearer ${oldToken}`); 67 + ``` 68 + 69 + ## API 70 + 71 + ### `SessionManager` 72 + 73 + #### Constructor Options 74 + 75 + | Option | Type | Default | Description | 76 + | -------------- | -------- | --------- | ---------------------------------- | 77 + | `cookieSecret` | `string` | required | Min 32 chars for Iron Session | 78 + | `cookieName` | `string` | `"sid"` | Cookie name for session storage | 79 + | `sessionTtl` | `number` | `604800` | Session TTL in seconds (7 days) | 80 + | `logger` | `Logger` | no-op | Optional logger for debugging | 81 + 82 + #### Methods 83 + 84 + - `getSessionFromRequest(req: Request)` - Extract session from cookie 85 + - `createSession(data: SessionData)` - Create Set-Cookie header 86 + - `getClearCookieHeader()` - Get header to clear session 87 + - `sealToken(data)` - Seal data for Bearer token 88 + - `unsealToken(token)` - Unseal Bearer token 89 + - `validateBearerToken(authHeader)` - Validate Authorization header 90 + - `refreshBearerToken(authHeader)` - Refresh Bearer token 91 + 92 + ## License 93 + 94 + MIT
+27
deno.json
··· 1 + { 2 + "$schema": "https://jsr.io/schema/config-file.v1.json", 3 + "name": "@tijs/atproto-sessions", 4 + "version": "0.1.0", 5 + "license": "MIT", 6 + "exports": "./mod.ts", 7 + "publish": { 8 + "include": ["mod.ts", "src/**/*.ts", "README.md", "LICENSE"], 9 + "exclude": ["**/*.test.ts"] 10 + }, 11 + "imports": { 12 + "iron-session": "npm:iron-session@8.0.4", 13 + "@std/assert": "jsr:@std/assert@1.0.16" 14 + }, 15 + "compilerOptions": { 16 + "strict": true, 17 + "noImplicitAny": true 18 + }, 19 + "tasks": { 20 + "test": "deno test --allow-all src/", 21 + "check": "deno check mod.ts", 22 + "fmt": "deno fmt", 23 + "lint": "deno lint", 24 + "quality": "deno fmt && deno lint && deno check mod.ts", 25 + "ci": "deno task quality && deno task test" 26 + } 27 + }
+45
deno.lock
··· 1 + { 2 + "version": "5", 3 + "specifiers": { 4 + "jsr:@std/assert@*": "1.0.16", 5 + "jsr:@std/assert@1.0.16": "1.0.16", 6 + "jsr:@std/internal@^1.0.12": "1.0.12", 7 + "npm:iron-session@8.0.4": "8.0.4" 8 + }, 9 + "jsr": { 10 + "@std/assert@1.0.16": { 11 + "integrity": "6a7272ed1eaa77defe76e5ff63ca705d9c495077e2d5fd0126d2b53fc5bd6532", 12 + "dependencies": [ 13 + "jsr:@std/internal" 14 + ] 15 + }, 16 + "@std/internal@1.0.12": { 17 + "integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027" 18 + } 19 + }, 20 + "npm": { 21 + "cookie@0.7.2": { 22 + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==" 23 + }, 24 + "iron-session@8.0.4": { 25 + "integrity": "sha512-9ivNnaKOd08osD0lJ3i6If23GFS2LsxyMU8Gf/uBUEgm8/8CC1hrrCHFDpMo3IFbpBgwoo/eairRsaD3c5itxA==", 26 + "dependencies": [ 27 + "cookie", 28 + "iron-webcrypto", 29 + "uncrypto" 30 + ] 31 + }, 32 + "iron-webcrypto@1.2.1": { 33 + "integrity": "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==" 34 + }, 35 + "uncrypto@0.1.3": { 36 + "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==" 37 + } 38 + }, 39 + "workspace": { 40 + "dependencies": [ 41 + "jsr:@std/assert@1.0.16", 42 + "npm:iron-session@8.0.4" 43 + ] 44 + } 45 + }
+49
mod.ts
··· 1 + /** 2 + * @module atproto-sessions 3 + * 4 + * Framework-agnostic session management for AT Protocol applications. 5 + * 6 + * Provides encrypted session cookies and mobile Bearer tokens using Iron Session. 7 + * Works with standard Web Request/Response APIs - no framework dependencies. 8 + * 9 + * @example 10 + * ```typescript 11 + * import { SessionManager } from "@tijs/atproto-sessions"; 12 + * 13 + * const sessions = new SessionManager({ 14 + * cookieSecret: Deno.env.get("COOKIE_SECRET")!, 15 + * cookieName: "sid", 16 + * sessionTtl: 60 * 60 * 24 * 14, // 14 days 17 + * }); 18 + * 19 + * // In a request handler 20 + * const { data, setCookieHeader, error } = await sessions.getSessionFromRequest(request); 21 + * 22 + * if (data) { 23 + * // User is authenticated, data.did contains their DID 24 + * // Set setCookieHeader on response to refresh session 25 + * } 26 + * ``` 27 + */ 28 + 29 + // Main class 30 + export { SessionManager } from "./src/sessions.ts"; 31 + 32 + // Types 33 + export type { 34 + Logger, 35 + MobileTokenData, 36 + SessionConfig, 37 + SessionData, 38 + SessionErrorInfo, 39 + SessionErrorType, 40 + SessionResult, 41 + } from "./src/types.ts"; 42 + 43 + // Errors 44 + export { 45 + ConfigurationError, 46 + CookieError, 47 + SessionError, 48 + TokenError, 49 + } from "./src/errors.ts";
+42
src/errors.ts
··· 1 + /** 2 + * Base error class for session-related errors 3 + */ 4 + export class SessionError extends Error { 5 + readonly code: string; 6 + 7 + constructor(message: string, code = "SESSION_ERROR") { 8 + super(message); 9 + this.name = "SessionError"; 10 + this.code = code; 11 + } 12 + } 13 + 14 + /** 15 + * Error thrown when session configuration is invalid 16 + */ 17 + export class ConfigurationError extends SessionError { 18 + constructor(message: string) { 19 + super(message, "CONFIGURATION_ERROR"); 20 + this.name = "ConfigurationError"; 21 + } 22 + } 23 + 24 + /** 25 + * Error thrown when cookie operations fail 26 + */ 27 + export class CookieError extends SessionError { 28 + constructor(message: string) { 29 + super(message, "COOKIE_ERROR"); 30 + this.name = "CookieError"; 31 + } 32 + } 33 + 34 + /** 35 + * Error thrown when mobile token operations fail 36 + */ 37 + export class TokenError extends SessionError { 38 + constructor(message: string) { 39 + super(message, "TOKEN_ERROR"); 40 + this.name = "TokenError"; 41 + } 42 + }
+290
src/sessions.test.ts
··· 1 + import { assertEquals, assertExists } from "@std/assert"; 2 + import { SessionManager } from "./sessions.ts"; 3 + import { ConfigurationError } from "./errors.ts"; 4 + import type { SessionData } from "./types.ts"; 5 + 6 + const TEST_SECRET = "test-secret-that-is-at-least-32-characters-long"; 7 + const TEST_DID = "did:plc:test123"; 8 + 9 + Deno.test("SessionManager - constructor validates config", async (t) => { 10 + await t.step("throws if cookieSecret is missing", () => { 11 + try { 12 + new SessionManager({ cookieSecret: "" }); 13 + throw new Error("Should have thrown"); 14 + } catch (e) { 15 + assertEquals(e instanceof ConfigurationError, true); 16 + assertEquals( 17 + (e as ConfigurationError).message, 18 + "cookieSecret is required", 19 + ); 20 + } 21 + }); 22 + 23 + await t.step("throws if cookieSecret is too short", () => { 24 + try { 25 + new SessionManager({ cookieSecret: "short" }); 26 + throw new Error("Should have thrown"); 27 + } catch (e) { 28 + assertEquals(e instanceof ConfigurationError, true); 29 + assertEquals( 30 + (e as ConfigurationError).message.includes("at least 32 characters"), 31 + true, 32 + ); 33 + } 34 + }); 35 + 36 + await t.step("accepts valid config", () => { 37 + const manager = new SessionManager({ cookieSecret: TEST_SECRET }); 38 + assertExists(manager); 39 + }); 40 + 41 + await t.step("accepts custom options", () => { 42 + const manager = new SessionManager({ 43 + cookieSecret: TEST_SECRET, 44 + cookieName: "custom", 45 + sessionTtl: 3600, 46 + logger: console, 47 + }); 48 + assertExists(manager); 49 + }); 50 + }); 51 + 52 + Deno.test("SessionManager - createSession", async (t) => { 53 + const manager = new SessionManager({ cookieSecret: TEST_SECRET }); 54 + 55 + await t.step("creates valid Set-Cookie header", async () => { 56 + const sessionData: SessionData = { 57 + did: TEST_DID, 58 + createdAt: Date.now(), 59 + lastAccessed: Date.now(), 60 + }; 61 + 62 + const header = await manager.createSession(sessionData); 63 + 64 + assertEquals(header.startsWith("sid="), true); 65 + assertEquals(header.includes("Path=/"), true); 66 + assertEquals(header.includes("HttpOnly"), true); 67 + assertEquals(header.includes("SameSite=Lax"), true); 68 + assertEquals(header.includes("Secure"), true); 69 + assertEquals(header.includes("Max-Age="), true); 70 + }); 71 + 72 + await t.step("uses custom cookie name", async () => { 73 + const customManager = new SessionManager({ 74 + cookieSecret: TEST_SECRET, 75 + cookieName: "custom_session", 76 + }); 77 + 78 + const sessionData: SessionData = { 79 + did: TEST_DID, 80 + createdAt: Date.now(), 81 + lastAccessed: Date.now(), 82 + }; 83 + 84 + const header = await customManager.createSession(sessionData); 85 + assertEquals(header.startsWith("custom_session="), true); 86 + }); 87 + }); 88 + 89 + Deno.test("SessionManager - getClearCookieHeader", async (t) => { 90 + await t.step("returns header that clears cookie", () => { 91 + const manager = new SessionManager({ cookieSecret: TEST_SECRET }); 92 + const header = manager.getClearCookieHeader(); 93 + 94 + assertEquals(header.startsWith("sid=;"), true); 95 + assertEquals(header.includes("Max-Age=0"), true); 96 + }); 97 + 98 + await t.step("uses custom cookie name", () => { 99 + const manager = new SessionManager({ 100 + cookieSecret: TEST_SECRET, 101 + cookieName: "my_session", 102 + }); 103 + const header = manager.getClearCookieHeader(); 104 + 105 + assertEquals(header.startsWith("my_session=;"), true); 106 + }); 107 + }); 108 + 109 + Deno.test("SessionManager - getSessionFromRequest", async (t) => { 110 + const manager = new SessionManager({ cookieSecret: TEST_SECRET }); 111 + 112 + await t.step("returns NO_COOKIE error when no cookie present", async () => { 113 + const req = new Request("http://example.com", { 114 + headers: {}, 115 + }); 116 + 117 + const result = await manager.getSessionFromRequest(req); 118 + 119 + assertEquals(result.data, null); 120 + assertEquals(result.error?.type, "NO_COOKIE"); 121 + }); 122 + 123 + await t.step( 124 + "returns NO_COOKIE error when wrong cookie present", 125 + async () => { 126 + const req = new Request("http://example.com", { 127 + headers: { 128 + cookie: "other_cookie=value", 129 + }, 130 + }); 131 + 132 + const result = await manager.getSessionFromRequest(req); 133 + 134 + assertEquals(result.data, null); 135 + assertEquals(result.error?.type, "NO_COOKIE"); 136 + }, 137 + ); 138 + 139 + await t.step("returns INVALID_COOKIE for invalid sealed data", async () => { 140 + // Note: iron-session's unsealData returns {} for invalid data instead of throwing 141 + // So we get INVALID_COOKIE (no DID) rather than SESSION_EXPIRED 142 + const req = new Request("http://example.com", { 143 + headers: { 144 + cookie: "sid=invalid_sealed_data", 145 + }, 146 + }); 147 + 148 + const result = await manager.getSessionFromRequest(req); 149 + 150 + assertEquals(result.data, null); 151 + assertEquals(result.error?.type, "INVALID_COOKIE"); 152 + }); 153 + 154 + await t.step("successfully extracts valid session", async () => { 155 + // First create a valid session cookie 156 + const sessionData: SessionData = { 157 + did: TEST_DID, 158 + createdAt: Date.now() - 1000, 159 + lastAccessed: Date.now() - 1000, 160 + }; 161 + const setCookie = await manager.createSession(sessionData); 162 + 163 + // Extract just the cookie value 164 + const cookieValue = setCookie.split(";")[0]; 165 + 166 + const req = new Request("http://example.com", { 167 + headers: { 168 + cookie: cookieValue, 169 + }, 170 + }); 171 + 172 + const result = await manager.getSessionFromRequest(req); 173 + 174 + assertExists(result.data); 175 + assertEquals(result.data.did, TEST_DID); 176 + assertEquals(result.data.createdAt, sessionData.createdAt); 177 + // lastAccessed should be updated 178 + assertEquals(result.data.lastAccessed > sessionData.lastAccessed, true); 179 + // Should return refreshed Set-Cookie header 180 + assertExists(result.setCookieHeader); 181 + assertEquals(result.error, undefined); 182 + }); 183 + 184 + await t.step("handles multiple cookies correctly", async () => { 185 + const sessionData: SessionData = { 186 + did: TEST_DID, 187 + createdAt: Date.now(), 188 + lastAccessed: Date.now(), 189 + }; 190 + const setCookie = await manager.createSession(sessionData); 191 + const cookieValue = setCookie.split(";")[0]; 192 + 193 + const req = new Request("http://example.com", { 194 + headers: { 195 + cookie: `other=value; ${cookieValue}; another=test`, 196 + }, 197 + }); 198 + 199 + const result = await manager.getSessionFromRequest(req); 200 + 201 + assertExists(result.data); 202 + assertEquals(result.data.did, TEST_DID); 203 + }); 204 + }); 205 + 206 + Deno.test("SessionManager - mobile token operations", async (t) => { 207 + const manager = new SessionManager({ cookieSecret: TEST_SECRET }); 208 + 209 + await t.step("sealToken creates valid token", async () => { 210 + const token = await manager.sealToken({ did: TEST_DID }); 211 + assertEquals(typeof token, "string"); 212 + assertEquals(token.length > 0, true); 213 + }); 214 + 215 + await t.step("unsealToken recovers data", async () => { 216 + const token = await manager.sealToken({ did: TEST_DID }); 217 + const data = await manager.unsealToken(token); 218 + 219 + assertExists(data); 220 + assertEquals(data.did, TEST_DID); 221 + }); 222 + 223 + await t.step("unsealToken returns null for invalid token", async () => { 224 + const data = await manager.unsealToken("invalid_token"); 225 + assertEquals(data, null); 226 + }); 227 + 228 + await t.step("unsealToken returns null for wrong secret", async () => { 229 + const otherManager = new SessionManager({ 230 + cookieSecret: "different-secret-that-is-at-least-32-chars", 231 + }); 232 + 233 + const token = await manager.sealToken({ did: TEST_DID }); 234 + const data = await otherManager.unsealToken(token); 235 + 236 + assertEquals(data, null); 237 + }); 238 + }); 239 + 240 + Deno.test("SessionManager - validateBearerToken", async (t) => { 241 + const manager = new SessionManager({ cookieSecret: TEST_SECRET }); 242 + 243 + await t.step("returns error for invalid header format", async () => { 244 + const result = await manager.validateBearerToken("Basic xxx"); 245 + 246 + assertEquals(result.data, null); 247 + assertEquals(result.error?.type, "INVALID_TOKEN"); 248 + assertEquals(result.error?.message, "Invalid authorization header format"); 249 + }); 250 + 251 + await t.step("returns error for invalid token", async () => { 252 + const result = await manager.validateBearerToken("Bearer invalid_token"); 253 + 254 + assertEquals(result.data, null); 255 + assertEquals(result.error?.type, "INVALID_TOKEN"); 256 + }); 257 + 258 + await t.step("successfully validates valid token", async () => { 259 + const token = await manager.sealToken({ did: TEST_DID }); 260 + const result = await manager.validateBearerToken(`Bearer ${token}`); 261 + 262 + assertExists(result.data); 263 + assertEquals(result.data.did, TEST_DID); 264 + assertEquals(result.error, undefined); 265 + }); 266 + }); 267 + 268 + Deno.test("SessionManager - refreshBearerToken", async (t) => { 269 + const manager = new SessionManager({ cookieSecret: TEST_SECRET }); 270 + 271 + await t.step("returns null for invalid token", async () => { 272 + const result = await manager.refreshBearerToken("Bearer invalid"); 273 + assertEquals(result, null); 274 + }); 275 + 276 + await t.step("returns new token for valid token", async () => { 277 + const originalToken = await manager.sealToken({ did: TEST_DID }); 278 + const newToken = await manager.refreshBearerToken( 279 + `Bearer ${originalToken}`, 280 + ); 281 + 282 + assertExists(newToken); 283 + assertEquals(typeof newToken, "string"); 284 + 285 + // New token should be valid and contain same DID 286 + const data = await manager.unsealToken(newToken); 287 + assertExists(data); 288 + assertEquals(data.did, TEST_DID); 289 + }); 290 + });
+305
src/sessions.ts
··· 1 + import { sealData, unsealData } from "iron-session"; 2 + 3 + import type { 4 + Logger, 5 + MobileTokenData, 6 + SessionConfig, 7 + SessionData, 8 + SessionResult, 9 + } from "./types.ts"; 10 + import { ConfigurationError } from "./errors.ts"; 11 + 12 + /** Default session TTL: 7 days in seconds */ 13 + const DEFAULT_SESSION_TTL = 60 * 60 * 24 * 7; 14 + 15 + /** Default cookie name */ 16 + const DEFAULT_COOKIE_NAME = "sid"; 17 + 18 + /** Minimum cookie secret length required by Iron Session */ 19 + const MIN_SECRET_LENGTH = 32; 20 + 21 + /** No-op logger for production use */ 22 + const noopLogger: Logger = { 23 + log: () => {}, 24 + warn: () => {}, 25 + error: () => {}, 26 + }; 27 + 28 + /** 29 + * Framework-agnostic session manager using Iron Session. 30 + * 31 + * Handles encrypted session cookies and mobile Bearer tokens 32 + * for AT Protocol applications. Works with standard Web Request/Response 33 + * APIs - no framework dependencies. 34 + * 35 + * @example 36 + * ```typescript 37 + * const sessions = new SessionManager({ 38 + * cookieSecret: process.env.COOKIE_SECRET, 39 + * cookieName: "sid", 40 + * sessionTtl: 60 * 60 * 24 * 14, // 14 days 41 + * }); 42 + * 43 + * // Get session from request 44 + * const { data, setCookieHeader, error } = await sessions.getSessionFromRequest(request); 45 + * 46 + * // Create new session 47 + * const setCookie = await sessions.createSession({ 48 + * did: "did:plc:abc123", 49 + * createdAt: Date.now(), 50 + * lastAccessed: Date.now(), 51 + * }); 52 + * ``` 53 + */ 54 + export class SessionManager { 55 + private readonly cookieSecret: string; 56 + private readonly cookieName: string; 57 + private readonly sessionTtl: number; 58 + private readonly logger: Logger; 59 + 60 + constructor(config: SessionConfig) { 61 + if (!config.cookieSecret) { 62 + throw new ConfigurationError("cookieSecret is required"); 63 + } 64 + if (config.cookieSecret.length < MIN_SECRET_LENGTH) { 65 + throw new ConfigurationError( 66 + `cookieSecret must be at least ${MIN_SECRET_LENGTH} characters for secure encryption`, 67 + ); 68 + } 69 + 70 + this.cookieSecret = config.cookieSecret; 71 + this.cookieName = config.cookieName ?? DEFAULT_COOKIE_NAME; 72 + this.sessionTtl = config.sessionTtl ?? DEFAULT_SESSION_TTL; 73 + this.logger = config.logger ?? noopLogger; 74 + } 75 + 76 + /** 77 + * Extract and validate session from a Request's cookies. 78 + * 79 + * Returns session data with a refreshed Set-Cookie header. 80 + * The Set-Cookie header should be set on responses to extend session lifetime. 81 + * 82 + * @param req - HTTP Request containing session cookie 83 + * @returns Session result with data, Set-Cookie header, or error 84 + */ 85 + async getSessionFromRequest( 86 + req: Request, 87 + ): Promise<SessionResult<SessionData>> { 88 + try { 89 + const cookieHeader = req.headers.get("cookie"); 90 + if (!cookieHeader?.includes(`${this.cookieName}=`)) { 91 + this.logger.log("No session cookie found in request"); 92 + return { 93 + data: null, 94 + error: { 95 + type: "NO_COOKIE", 96 + message: "No session cookie found in request", 97 + }, 98 + }; 99 + } 100 + 101 + // Parse cookie - handle '=' characters in sealed value 102 + const cookies = cookieHeader.split(";").map((c) => c.trim()); 103 + const cookiePrefix = `${this.cookieName}=`; 104 + const sessionCookie = cookies 105 + .find((c) => c.startsWith(cookiePrefix)) 106 + ?.substring(cookiePrefix.length); 107 + 108 + if (!sessionCookie) { 109 + this.logger.log("Session cookie found but could not be parsed"); 110 + return { 111 + data: null, 112 + error: { 113 + type: "INVALID_COOKIE", 114 + message: "Session cookie could not be parsed", 115 + }, 116 + }; 117 + } 118 + 119 + // Unseal session data 120 + let sessionData: SessionData; 121 + try { 122 + sessionData = await unsealData(decodeURIComponent(sessionCookie), { 123 + password: this.cookieSecret, 124 + }) as SessionData; 125 + } catch (unsealError) { 126 + this.logger.error("Failed to unseal session cookie:", { 127 + error: unsealError instanceof Error 128 + ? unsealError.message 129 + : String(unsealError), 130 + }); 131 + return { 132 + data: null, 133 + error: { 134 + type: "SESSION_EXPIRED", 135 + message: "Session cookie is invalid or expired", 136 + details: unsealError instanceof Error 137 + ? unsealError.message 138 + : String(unsealError), 139 + }, 140 + }; 141 + } 142 + 143 + if (!sessionData?.did) { 144 + this.logger.error("No DID found in session data:", sessionData); 145 + return { 146 + data: null, 147 + error: { 148 + type: "INVALID_COOKIE", 149 + message: "No DID found in session data", 150 + }, 151 + }; 152 + } 153 + 154 + this.logger.log( 155 + `Session extracted: DID=${sessionData.did}, created=${ 156 + new Date(sessionData.createdAt).toISOString() 157 + }`, 158 + ); 159 + 160 + // Create refreshed session with updated lastAccessed 161 + const refreshedData: SessionData = { 162 + did: sessionData.did, 163 + createdAt: sessionData.createdAt, 164 + lastAccessed: Date.now(), 165 + }; 166 + 167 + const setCookieHeader = await this.createSession(refreshedData); 168 + 169 + this.logger.log( 170 + `Session refreshed for DID: ${sessionData.did}, expires in ${ 171 + Math.round(this.sessionTtl / 86400) 172 + } days`, 173 + ); 174 + 175 + return { 176 + data: refreshedData, 177 + setCookieHeader, 178 + }; 179 + } catch (error) { 180 + this.logger.error("Failed to get session from request:", { 181 + error: error instanceof Error ? error.message : String(error), 182 + }); 183 + return { 184 + data: null, 185 + error: { 186 + type: "UNKNOWN", 187 + message: error instanceof Error ? error.message : "Unknown error", 188 + details: error, 189 + }, 190 + }; 191 + } 192 + } 193 + 194 + /** 195 + * Create a new session and return Set-Cookie header. 196 + * 197 + * @param data - Session data to store (did, createdAt, lastAccessed) 198 + * @returns Set-Cookie header string to set on response 199 + */ 200 + async createSession(data: SessionData): Promise<string> { 201 + const sealedSession = await sealData(data, { 202 + password: this.cookieSecret, 203 + ttl: this.sessionTtl, 204 + }); 205 + 206 + return `${this.cookieName}=${ 207 + encodeURIComponent(sealedSession) 208 + }; Path=/; HttpOnly; SameSite=Lax; Secure; Max-Age=${this.sessionTtl}`; 209 + } 210 + 211 + /** 212 + * Get Set-Cookie header that clears the session cookie. 213 + * 214 + * Use this when logging out or when session is invalid. 215 + * 216 + * @returns Set-Cookie header string that clears the session 217 + */ 218 + getClearCookieHeader(): string { 219 + return `${this.cookieName}=; Path=/; HttpOnly; SameSite=Lax; Secure; Max-Age=0`; 220 + } 221 + 222 + /** 223 + * Seal data into a mobile Bearer token. 224 + * 225 + * @param data - Data to seal (typically just { did }) 226 + * @returns Sealed token string 227 + */ 228 + async sealToken(data: MobileTokenData): Promise<string> { 229 + return await sealData(data, { 230 + password: this.cookieSecret, 231 + }); 232 + } 233 + 234 + /** 235 + * Unseal a mobile Bearer token. 236 + * 237 + * @param token - Sealed token string 238 + * @returns Unsealed data, or null if invalid 239 + */ 240 + async unsealToken(token: string): Promise<MobileTokenData | null> { 241 + try { 242 + const data = await unsealData(token, { 243 + password: this.cookieSecret, 244 + }) as MobileTokenData; 245 + 246 + if (!data?.did) { 247 + return null; 248 + } 249 + 250 + return data; 251 + } catch { 252 + return null; 253 + } 254 + } 255 + 256 + /** 257 + * Validate a Bearer token from Authorization header. 258 + * 259 + * @param authHeader - Authorization header value (e.g., "Bearer xxx") 260 + * @returns Session result with token data or error 261 + */ 262 + async validateBearerToken( 263 + authHeader: string, 264 + ): Promise<SessionResult<MobileTokenData>> { 265 + if (!authHeader.startsWith("Bearer ")) { 266 + return { 267 + data: null, 268 + error: { 269 + type: "INVALID_TOKEN", 270 + message: "Invalid authorization header format", 271 + }, 272 + }; 273 + } 274 + 275 + const token = authHeader.slice(7); 276 + const data = await this.unsealToken(token); 277 + 278 + if (!data) { 279 + return { 280 + data: null, 281 + error: { 282 + type: "INVALID_TOKEN", 283 + message: "Invalid or expired token", 284 + }, 285 + }; 286 + } 287 + 288 + return { data }; 289 + } 290 + 291 + /** 292 + * Refresh a mobile Bearer token with new seal. 293 + * 294 + * @param authHeader - Authorization header with current Bearer token 295 + * @returns New sealed token, or null if invalid 296 + */ 297 + async refreshBearerToken(authHeader: string): Promise<string | null> { 298 + const result = await this.validateBearerToken(authHeader); 299 + if (!result.data) { 300 + return null; 301 + } 302 + 303 + return await this.sealToken(result.data); 304 + } 305 + }
+100
src/types.ts
··· 1 + /** 2 + * Logger interface for custom logging implementations 3 + */ 4 + export interface Logger { 5 + log(...args: unknown[]): void; 6 + warn(...args: unknown[]): void; 7 + error(...args: unknown[]): void; 8 + } 9 + 10 + /** 11 + * Configuration for SessionManager 12 + */ 13 + export interface SessionConfig { 14 + /** 15 + * Secret for Iron Session cookie encryption. 16 + * Must be at least 32 characters for secure encryption. 17 + */ 18 + cookieSecret: string; 19 + 20 + /** 21 + * Cookie name for session storage. 22 + * @default "sid" 23 + */ 24 + cookieName?: string; 25 + 26 + /** 27 + * Session TTL (time-to-live) in seconds. 28 + * Controls how long sessions remain valid. 29 + * @default 604800 (7 days) 30 + */ 31 + sessionTtl?: number; 32 + 33 + /** 34 + * Optional logger for debugging and monitoring. 35 + * Defaults to a no-op logger. 36 + */ 37 + logger?: Logger; 38 + } 39 + 40 + /** 41 + * Session data stored in the encrypted cookie. 42 + * Contains user identity and timing information. 43 + */ 44 + export interface SessionData { 45 + /** User's DID (Decentralized Identifier) */ 46 + did: string; 47 + 48 + /** Timestamp when session was created */ 49 + createdAt: number; 50 + 51 + /** Timestamp of last access (for session refresh) */ 52 + lastAccessed: number; 53 + } 54 + 55 + /** 56 + * Error types for session operations 57 + */ 58 + export type SessionErrorType = 59 + | "NO_COOKIE" 60 + | "INVALID_COOKIE" 61 + | "SESSION_EXPIRED" 62 + | "INVALID_TOKEN" 63 + | "UNKNOWN"; 64 + 65 + /** 66 + * Error information from session operations 67 + */ 68 + export interface SessionErrorInfo { 69 + /** Type of error for programmatic handling */ 70 + type: SessionErrorType; 71 + 72 + /** Human-readable error message */ 73 + message: string; 74 + 75 + /** Additional error details (e.g., original error) */ 76 + details?: unknown; 77 + } 78 + 79 + /** 80 + * Result from session operations. 81 + * Contains either data or error information. 82 + */ 83 + export interface SessionResult<T = unknown> { 84 + /** Session data, or null if not found/invalid */ 85 + data: T | null; 86 + 87 + /** Set-Cookie header for session refresh (when data is valid) */ 88 + setCookieHeader?: string; 89 + 90 + /** Error information if session retrieval failed */ 91 + error?: SessionErrorInfo; 92 + } 93 + 94 + /** 95 + * Mobile token data - minimal payload sealed in Bearer tokens 96 + */ 97 + export interface MobileTokenData { 98 + /** User's DID */ 99 + did: string; 100 + }