Framework-agnostic OAuth integration for AT Protocol (Bluesky) applications.

Handle unrecoverable session errors in getOAuthSession

Return null instead of throwing for expired/revoked/corrupt sessions,
and clean up dead session data from storage. Only re-throw transient
NetworkError so callers can decide whether to retry.

+177 -4
+13
CHANGELOG.md
··· 2 2 3 3 All notable changes to this project will be documented in this file. 4 4 5 + ## [2.10.0] - 2026-02-18 6 + 7 + ### Changed 8 + 9 + - **`getOAuthSession` handles unrecoverable errors gracefully**: Instead of 10 + re-throwing all errors from the underlying OAuth client's `restore()` method, 11 + `getOAuthSession` now returns `null` for unrecoverable session errors (expired 12 + tokens, revoked tokens, corrupt data, deserialization failures) and cleans up 13 + the dead session from storage. Only transient `NetworkError` is re-thrown so 14 + callers can decide whether to retry. This eliminates the need for consumers to 15 + string-match on error messages to distinguish expected session failures from 16 + bugs. 17 + 5 18 ## [2.9.0] - 2026-02-15 6 19 7 20 ### Added
+1 -1
deno.json
··· 1 1 { 2 2 "$schema": "https://jsr.io/schema/config-file.v1.json", 3 3 "name": "@tijs/atproto-oauth", 4 - "version": "2.9.0", 4 + "version": "2.10.0", 5 5 "license": "MIT", 6 6 "exports": "./mod.ts", 7 7 "publish": {
+138
src/sessions.test.ts
··· 1 + import { assertEquals, assertRejects } from "@std/assert"; 2 + import { MemoryStorage } from "@tijs/atproto-storage"; 3 + import { NetworkError, SessionError } from "@tijs/oauth-client-deno"; 4 + import { OAuthSessions } from "./sessions.ts"; 5 + import type { OAuthClientInterface, SessionInterface } from "./types.ts"; 6 + import { noopLogger } from "./types.ts"; 7 + 8 + /** Create a fake session for testing */ 9 + function fakeSession(did: string): SessionInterface { 10 + return { 11 + did, 12 + accessToken: "test-access-token", 13 + pdsUrl: "https://pds.example.com", 14 + timeUntilExpiry: 3600000, 15 + makeRequest: () => Promise.resolve(new Response()), 16 + toJSON: () => ({ did, accessToken: "test-access-token" }), 17 + }; 18 + } 19 + 20 + /** Create a mock OAuth client */ 21 + function mockOAuthClient( 22 + restoreFn: (sessionId: string) => Promise<SessionInterface | null>, 23 + ): OAuthClientInterface { 24 + return { 25 + authorize: () => Promise.resolve(new URL("https://auth.example.com")), 26 + callback: () => 27 + Promise.resolve({ session: fakeSession("did:plc:test"), state: null }), 28 + restore: restoreFn, 29 + }; 30 + } 31 + 32 + Deno.test("getOAuthSession - returns session on success", async () => { 33 + const session = fakeSession("did:plc:abc"); 34 + const sessions = new OAuthSessions({ 35 + oauthClient: mockOAuthClient(() => Promise.resolve(session)), 36 + storage: new MemoryStorage(), 37 + sessionTtl: 3600, 38 + logger: noopLogger, 39 + }); 40 + 41 + const result = await sessions.getOAuthSession("did:plc:abc"); 42 + assertEquals(result?.did, "did:plc:abc"); 43 + }); 44 + 45 + Deno.test("getOAuthSession - returns null when restore returns null", async () => { 46 + const sessions = new OAuthSessions({ 47 + oauthClient: mockOAuthClient(() => Promise.resolve(null)), 48 + storage: new MemoryStorage(), 49 + sessionTtl: 3600, 50 + logger: noopLogger, 51 + }); 52 + 53 + const result = await sessions.getOAuthSession("did:plc:abc"); 54 + assertEquals(result, null); 55 + }); 56 + 57 + Deno.test("getOAuthSession - returns null on SessionError (corrupt session)", async () => { 58 + const storage = new MemoryStorage(); 59 + await storage.set("session:did:plc:abc", { corrupt: "data" }); 60 + 61 + const sessions = new OAuthSessions({ 62 + oauthClient: mockOAuthClient(() => { 63 + throw new SessionError("Failed to restore session: did:plc:abc"); 64 + }), 65 + storage, 66 + sessionTtl: 3600, 67 + logger: noopLogger, 68 + }); 69 + 70 + const result = await sessions.getOAuthSession("did:plc:abc"); 71 + assertEquals(result, null); 72 + 73 + // Should have cleaned up the dead session from storage 74 + const stored = await storage.get("session:did:plc:abc"); 75 + assertEquals(stored, null); 76 + }); 77 + 78 + Deno.test("getOAuthSession - returns null on token expiry errors", async () => { 79 + const storage = new MemoryStorage(); 80 + await storage.set("session:did:plc:abc", { expired: true }); 81 + 82 + const sessions = new OAuthSessions({ 83 + oauthClient: mockOAuthClient(() => { 84 + throw new Error("Refresh token has expired"); 85 + }), 86 + storage, 87 + sessionTtl: 3600, 88 + logger: noopLogger, 89 + }); 90 + 91 + const result = await sessions.getOAuthSession("did:plc:abc"); 92 + assertEquals(result, null); 93 + 94 + // Should have cleaned up the dead session from storage 95 + const stored = await storage.get("session:did:plc:abc"); 96 + assertEquals(stored, null); 97 + }); 98 + 99 + Deno.test("getOAuthSession - re-throws NetworkError (transient)", async () => { 100 + const sessions = new OAuthSessions({ 101 + oauthClient: mockOAuthClient(() => { 102 + throw new NetworkError("Connection refused"); 103 + }), 104 + storage: new MemoryStorage(), 105 + sessionTtl: 3600, 106 + logger: noopLogger, 107 + }); 108 + 109 + await assertRejects( 110 + () => sessions.getOAuthSession("did:plc:abc"), 111 + NetworkError, 112 + ); 113 + }); 114 + 115 + Deno.test("getOAuthSession - cleanup failure does not prevent null return", async () => { 116 + // Storage that fails on delete 117 + const storage = new MemoryStorage(); 118 + const originalDelete = storage.delete.bind(storage); 119 + storage.delete = () => { 120 + throw new Error("Storage delete failed"); 121 + }; 122 + 123 + const sessions = new OAuthSessions({ 124 + oauthClient: mockOAuthClient(() => { 125 + throw new SessionError("Failed to restore session: did:plc:abc"); 126 + }), 127 + storage, 128 + sessionTtl: 3600, 129 + logger: noopLogger, 130 + }); 131 + 132 + // Should still return null even if cleanup fails 133 + const result = await sessions.getOAuthSession("did:plc:abc"); 134 + assertEquals(result, null); 135 + 136 + // Restore delete for cleanup 137 + storage.delete = originalDelete; 138 + });
+25 -3
src/sessions.ts
··· 4 4 */ 5 5 6 6 import type { OAuthStorage } from "@tijs/atproto-storage"; 7 + import { NetworkError } from "@tijs/oauth-client-deno"; 7 8 import type { 8 9 Logger, 9 10 OAuthClientInterface, ··· 81 82 82 83 return session; 83 84 } catch (error) { 84 - this.logger.error(`Failed to restore OAuth session for DID ${did}:`, { 85 + // NetworkError is transient — re-throw so callers can retry or handle 86 + if (error instanceof NetworkError) { 87 + this.logger.warn(`Network error restoring session for DID ${did}:`, { 88 + error: error.message, 89 + }); 90 + throw error; 91 + } 92 + 93 + // All other errors mean the session is unrecoverable (expired tokens, 94 + // revoked tokens, corrupt data, deserialization failures). Return null 95 + // per the method contract and clean up the dead session from storage. 96 + this.logger.warn(`Session unrecoverable for DID ${did}, removing:`, { 85 97 error: error instanceof Error ? error.message : String(error), 86 98 errorName: error instanceof Error ? error.constructor.name : "Unknown", 87 - stack: error instanceof Error ? error.stack : undefined, 88 99 }); 89 - throw error; // Re-throw to let caller handle specific error types 100 + 101 + try { 102 + await this.storage.delete(`session:${did}`); 103 + } catch (cleanupError) { 104 + this.logger.error(`Failed to clean up session for DID ${did}:`, { 105 + error: cleanupError instanceof Error 106 + ? cleanupError.message 107 + : String(cleanupError), 108 + }); 109 + } 110 + 111 + return null; 90 112 } 91 113 } 92 114