Framework-agnostic OAuth integration for AT Protocol (Bluesky) applications.
1/**
2 * OAuth session management
3 * Framework-agnostic session storage and retrieval
4 */
5
6import type { OAuthStorage } from "@tijs/atproto-storage";
7import { NetworkError } from "@tijs/oauth-client-deno";
8import type {
9 Logger,
10 OAuthClientInterface,
11 OAuthSessionsInterface,
12 SessionInterface,
13} from "./types.ts";
14import { noopLogger } from "./types.ts";
15
16/**
17 * Configuration for OAuthSessions
18 */
19export interface OAuthSessionsConfig {
20 /** OAuth client for session restoration */
21 oauthClient: OAuthClientInterface;
22
23 /** Storage for OAuth session data */
24 storage: OAuthStorage;
25
26 /** Session TTL in seconds */
27 sessionTtl: number;
28
29 /** Optional logger */
30 logger?: Logger;
31}
32
33/**
34 * OAuth session manager - handles storing and restoring OAuth sessions
35 */
36export class OAuthSessions implements OAuthSessionsInterface {
37 private readonly oauthClient: OAuthClientInterface;
38 private readonly storage: OAuthStorage;
39 private readonly sessionTtl: number;
40 private readonly logger: Logger;
41
42 constructor(config: OAuthSessionsConfig) {
43 this.oauthClient = config.oauthClient;
44 this.storage = config.storage;
45 this.sessionTtl = config.sessionTtl;
46 this.logger = config.logger ?? noopLogger;
47 }
48
49 /**
50 * Get OAuth session for a DID with automatic token refresh
51 */
52 async getOAuthSession(did: string): Promise<SessionInterface | null> {
53 this.logger.debug(`Restoring OAuth session for DID: ${did}`);
54
55 try {
56 // The OAuth client's restore() method handles automatic token refresh
57 const session = await this.oauthClient.restore(did);
58
59 if (session) {
60 this.logger.info(`OAuth session restored successfully for DID: ${did}`);
61
62 // Log token expiration information if available
63 if (session.timeUntilExpiry !== undefined) {
64 const timeUntilExpiryMinutes = Math.round(
65 session.timeUntilExpiry / 1000 / 60,
66 );
67 const wasLikelyRefreshed = session.timeUntilExpiry > (60 * 60 * 1000); // More than 1 hour
68 const now = Date.now();
69 const expiresAt = now + session.timeUntilExpiry;
70
71 this.logger.debug(`Token status for DID ${did}:`, {
72 expiresAt: new Date(expiresAt).toISOString(),
73 currentTime: new Date(now).toISOString(),
74 timeUntilExpiryMinutes,
75 wasLikelyRefreshed,
76 hasRefreshToken: !!session.refreshToken,
77 });
78 }
79 } else {
80 this.logger.debug(`OAuth session not found for DID: ${did}`);
81 }
82
83 return session;
84 } catch (error) {
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:`, {
97 error: error instanceof Error ? error.message : String(error),
98 errorName: error instanceof Error ? error.constructor.name : "Unknown",
99 });
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;
112 }
113 }
114
115 /**
116 * Save OAuth session to storage
117 */
118 async saveOAuthSession(session: SessionInterface): Promise<void> {
119 this.logger.debug(`Saving OAuth session for DID: ${session.did}`);
120
121 await this.storage.set(`session:${session.did}`, session.toJSON(), {
122 ttl: this.sessionTtl,
123 });
124
125 this.logger.info(`OAuth session saved for DID: ${session.did}`);
126 }
127
128 /**
129 * Delete OAuth session from storage
130 */
131 async deleteOAuthSession(did: string): Promise<void> {
132 this.logger.debug(`Deleting OAuth session for DID: ${did}`);
133
134 await this.storage.delete(`session:${did}`);
135
136 this.logger.info(`OAuth session deleted for DID: ${did}`);
137 }
138}