Highly ambitious ATProtocol AppView service and sdks
at fix-postgres 354 lines 10 kB view raw
1import type { 2 OAuthConfig, 3 OAuthTokens, 4 OAuthAuthorizeResult, 5 OAuthTokenResponse, 6 OAuthStorage, 7 OAuthUserInfo, 8} from "./types.ts"; 9import { PKCEUtils } from "./pkce.ts"; 10 11export class OAuthClient { 12 private config: OAuthConfig; 13 private storage: OAuthStorage; 14 private sessionId: string; 15 private refreshPromise?: Promise<void>; 16 17 constructor(config: OAuthConfig, storage: OAuthStorage, sessionId: string) { 18 this.config = config; 19 this.storage = storage; 20 this.sessionId = sessionId; 21 } 22 23 async authorize(params: { 24 loginHint: string; 25 state?: string; 26 scope?: string; 27 }): Promise<OAuthAuthorizeResult> { 28 const pkce = await PKCEUtils.generatePKCEChallenge(); 29 const state = params.state || PKCEUtils.generateState(); 30 31 const parParams = { 32 client_id: this.config.clientId, 33 response_type: "code", 34 redirect_uri: this.config.redirectUri, 35 state, 36 code_challenge: pkce.codeChallenge, 37 code_challenge_method: pkce.codeChallengeMethod, 38 scope: 39 params.scope || 40 this.config.scopes?.join(" ") || 41 "atproto:atproto atproto:transition:generic", 42 login_hint: params.loginHint, 43 }; 44 45 const parResponse = await this.makeRequest<{ request_uri: string }>( 46 "oauth/par", 47 "POST", 48 parParams, 49 false 50 ); 51 52 await this.storage.setState(state, pkce.codeVerifier); 53 54 const authParams = new URLSearchParams({ 55 client_id: this.config.clientId, 56 request_uri: parResponse.request_uri, 57 }); 58 59 const authorizationUrl = `${ 60 this.config.authBaseUrl 61 }/oauth/authorize?${authParams.toString()}`; 62 63 return { 64 authorizationUrl, 65 codeVerifier: pkce.codeVerifier, 66 state, 67 }; 68 } 69 70 async handleCallback(params: { 71 code: string; 72 state: string; 73 }): Promise<OAuthTokens> { 74 // Retrieve the code verifier from storage using the state 75 const codeVerifier = await this.storage.getState(params.state); 76 if (!codeVerifier) { 77 throw new Error("Invalid or expired OAuth state"); 78 } 79 80 const tokenResponse = await this.makeRequest<OAuthTokenResponse>( 81 "oauth/token", 82 "POST", 83 { 84 grant_type: "authorization_code", 85 code: params.code, 86 redirect_uri: this.config.redirectUri, 87 client_id: this.config.clientId, 88 client_secret: this.config.clientSecret, 89 code_verifier: codeVerifier, 90 }, 91 false 92 ); 93 94 const tokens = this.transformTokenResponse(tokenResponse); 95 96 await this.storage.clearState(params.state); 97 98 return tokens; 99 } 100 101 async refreshAccessToken(): Promise<OAuthTokens> { 102 const tokens = await this.storage.getTokens(this.sessionId); 103 if (!tokens?.refreshToken) { 104 throw new Error("No refresh token available"); 105 } 106 107 try { 108 const tokenResponse = await this.makeRequest<OAuthTokenResponse>( 109 "oauth/token", 110 "POST", 111 { 112 grant_type: "refresh_token", 113 refresh_token: tokens.refreshToken, 114 client_id: this.config.clientId, 115 client_secret: this.config.clientSecret, 116 }, 117 false 118 ); 119 120 const newTokens = this.transformTokenResponse(tokenResponse); 121 await this.storage.setTokens(newTokens, this.sessionId); 122 return newTokens; 123 } catch (error) { 124 await this.storage.clearTokens(this.sessionId); 125 throw new Error(`Failed to refresh token: ${error}`); 126 } 127 } 128 129 async ensureValidToken(): Promise<OAuthTokens> { 130 const tokens = await this.storage.getTokens(this.sessionId); 131 132 if (!tokens) { 133 throw new Error("No access token available. Please authenticate first."); 134 } 135 136 // Check if token is still valid 137 if (!this.isTokenExpired(tokens)) { 138 return tokens; 139 } 140 141 if (!tokens.refreshToken) { 142 throw new Error( 143 "Access token expired and no refresh token available. Please re-authenticate." 144 ); 145 } 146 147 // Check if a refresh is already in progress 148 if (this.refreshPromise) { 149 await this.refreshPromise; 150 const refreshedTokens = await this.storage.getTokens(this.sessionId); 151 if (!refreshedTokens) { 152 throw new Error("Failed to refresh tokens"); 153 } 154 return refreshedTokens; 155 } 156 157 // Start a new refresh 158 this.refreshPromise = this.refreshAccessToken().then(() => undefined); 159 160 try { 161 await this.refreshPromise; 162 const refreshedTokens = await this.storage.getTokens(this.sessionId); 163 if (!refreshedTokens) { 164 throw new Error("Failed to refresh tokens"); 165 } 166 return refreshedTokens; 167 } finally { 168 this.refreshPromise = undefined; 169 } 170 } 171 172 async getUserInfo(): Promise<OAuthUserInfo | null> { 173 const tokens = await this.storage.getTokens(this.sessionId); 174 if (!tokens) { 175 return null; 176 } 177 178 try { 179 const userInfo = await this.makeRequestWithTokens<OAuthUserInfo>( 180 "oauth/userinfo", 181 "GET", 182 tokens, 183 undefined 184 ); 185 return userInfo; 186 } catch (error) { 187 console.error("Failed to fetch user info:", error); 188 return null; 189 } 190 } 191 192 async isAuthenticated(): Promise<boolean> { 193 const tokens = await this.storage.getTokens(this.sessionId); 194 return !!tokens?.accessToken; 195 } 196 197 async logout(): Promise<void> { 198 await this.storage.clearTokens(this.sessionId); 199 } 200 201 async getAuthenticationInfo(): Promise<{ 202 isAuthenticated: boolean; 203 expiresAt?: number; 204 scope?: string; 205 }> { 206 const tokens = await this.storage.getTokens(this.sessionId); 207 return { 208 isAuthenticated: !!tokens?.accessToken, 209 expiresAt: tokens?.expiresAt, 210 scope: tokens?.scope, 211 }; 212 } 213 214 private isTokenExpired(tokens: OAuthTokens): boolean { 215 if (!tokens.expiresAt) return false; 216 return Date.now() >= tokens.expiresAt - 60000; // 60 second buffer 217 } 218 219 private transformTokenResponse(response: OAuthTokenResponse): OAuthTokens { 220 const tokenType = response.token_type 221 ? response.token_type.charAt(0).toUpperCase() + 222 response.token_type.slice(1).toLowerCase() 223 : "Bearer"; 224 225 return { 226 accessToken: response.access_token, 227 refreshToken: response.refresh_token, 228 tokenType, 229 scope: response.scope, 230 expiresAt: response.expires_in 231 ? Date.now() + response.expires_in * 1000 232 : undefined, 233 expiresIn: response.expires_in, 234 }; 235 } 236 237 private async makeRequestWithTokens<T = unknown>( 238 endpoint: string, 239 method: "GET" | "POST", 240 tokens: OAuthTokens, 241 params?: Record<string, string | undefined> 242 ): Promise<T> { 243 const url = `${this.config.authBaseUrl}/${endpoint}`; 244 245 const requestInit: RequestInit = { 246 method, 247 headers: { 248 Authorization: `${tokens.tokenType} ${tokens.accessToken}`, 249 }, 250 }; 251 252 if (method === "GET" && params) { 253 const searchParams = new URLSearchParams(); 254 Object.entries(params).forEach(([key, value]) => { 255 if (value !== undefined && value !== null) { 256 searchParams.append(key, String(value)); 257 } 258 }); 259 const queryString = searchParams.toString(); 260 if (queryString) { 261 const urlWithParams = `${url}?${queryString}`; 262 const response = await fetch(urlWithParams, requestInit); 263 if (!response.ok) { 264 throw new Error( 265 `Request failed: ${response.status} ${response.statusText}` 266 ); 267 } 268 return (await response.json()) as T; 269 } 270 } else if (method === "POST" && params) { 271 (requestInit.headers as Record<string, string>)["Content-Type"] = 272 "application/x-www-form-urlencoded"; 273 requestInit.body = new URLSearchParams(params as Record<string, string>); 274 } 275 276 const response = await fetch(url, requestInit); 277 if (!response.ok) { 278 throw new Error( 279 `Request failed: ${response.status} ${response.statusText}` 280 ); 281 } 282 283 return (await response.json()) as T; 284 } 285 286 private async makeRequest<T = unknown>( 287 endpoint: string, 288 method: "GET" | "POST", 289 params?: Record<string, string | undefined>, 290 requiresAuth: boolean = false 291 ): Promise<T> { 292 const url = `${this.config.authBaseUrl}/${endpoint}`; 293 294 const requestInit: RequestInit = { 295 method, 296 headers: {}, 297 }; 298 299 if (requiresAuth) { 300 const tokens = await this.ensureValidToken(); 301 (requestInit.headers as Record<string, string>)[ 302 "Authorization" 303 ] = `${tokens.tokenType} ${tokens.accessToken}`; 304 } else if (endpoint === "oauth/par" || endpoint === "oauth/token") { 305 const credentials = btoa( 306 `${this.config.clientId}:${this.config.clientSecret}` 307 ); 308 (requestInit.headers as Record<string, string>)[ 309 "Authorization" 310 ] = `Basic ${credentials}`; 311 } 312 313 if (method === "GET" && params) { 314 const searchParams = new URLSearchParams(); 315 Object.entries(params).forEach(([key, value]) => { 316 if (value !== undefined && value !== null) { 317 searchParams.append(key, String(value)); 318 } 319 }); 320 const queryString = searchParams.toString(); 321 if (queryString) { 322 const urlWithParams = `${url}?${queryString}`; 323 const response = await fetch(urlWithParams, requestInit); 324 if (!response.ok) { 325 throw new Error( 326 `Request failed: ${response.status} ${response.statusText}` 327 ); 328 } 329 return (await response.json()) as T; 330 } 331 } else if (method === "POST" && params) { 332 (requestInit.headers as Record<string, string>)["Content-Type"] = 333 "application/x-www-form-urlencoded"; 334 requestInit.body = new URLSearchParams(params as Record<string, string>); 335 } 336 337 const response = await fetch(url, requestInit); 338 if (!response.ok) { 339 throw new Error( 340 `Request failed: ${response.status} ${response.statusText}` 341 ); 342 } 343 344 return (await response.json()) as T; 345 } 346 347 async getTokens(): Promise<OAuthTokens | null> { 348 return await this.storage.getTokens(this.sessionId); 349 } 350 351 getSessionId(): string { 352 return this.sessionId; 353 } 354}