forked from
slices.network/slices
Highly ambitious ATProtocol AppView service and sdks
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}