this repo has no description
1const OAUTH_STATE_KEY = "tranquil_pds_oauth_state";
2const OAUTH_VERIFIER_KEY = "tranquil_pds_oauth_verifier";
3const SCOPES = [
4 "atproto",
5 "repo:*?action=create",
6 "repo:*?action=update",
7 "repo:*?action=delete",
8 "blob:*/*",
9].join(" ");
10const CLIENT_ID = !(import.meta.env.DEV)
11 ? `${window.location.origin}/oauth/client-metadata.json`
12 : `http://localhost/?scope=${SCOPES}`;
13const REDIRECT_URI = `${window.location.origin}/`;
14
15interface OAuthState {
16 state: string;
17 codeVerifier: string;
18 returnTo?: string;
19}
20
21function generateRandomString(length: number): string {
22 const array = new Uint8Array(length);
23 crypto.getRandomValues(array);
24 return Array.from(array, (byte) => byte.toString(16).padStart(2, "0")).join(
25 "",
26 );
27}
28
29async function sha256(plain: string): Promise<ArrayBuffer> {
30 const encoder = new TextEncoder();
31 const data = encoder.encode(plain);
32 return crypto.subtle.digest("SHA-256", data);
33}
34
35function base64UrlEncode(buffer: ArrayBuffer): string {
36 const bytes = new Uint8Array(buffer);
37 let binary = "";
38 for (const byte of bytes) {
39 binary += String.fromCharCode(byte);
40 }
41 return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(
42 /=+$/,
43 "",
44 );
45}
46
47async function generateCodeChallenge(verifier: string): Promise<string> {
48 const hash = await sha256(verifier);
49 return base64UrlEncode(hash);
50}
51
52function generateState(): string {
53 return generateRandomString(32);
54}
55
56function generateCodeVerifier(): string {
57 return generateRandomString(32);
58}
59
60function saveOAuthState(state: OAuthState): void {
61 sessionStorage.setItem(OAUTH_STATE_KEY, state.state);
62 sessionStorage.setItem(OAUTH_VERIFIER_KEY, state.codeVerifier);
63}
64
65function getOAuthState(): OAuthState | null {
66 const state = sessionStorage.getItem(OAUTH_STATE_KEY);
67 const codeVerifier = sessionStorage.getItem(OAUTH_VERIFIER_KEY);
68 if (!state || !codeVerifier) return null;
69 return { state, codeVerifier };
70}
71
72function clearOAuthState(): void {
73 sessionStorage.removeItem(OAUTH_STATE_KEY);
74 sessionStorage.removeItem(OAUTH_VERIFIER_KEY);
75}
76
77export async function startOAuthLogin(): Promise<void> {
78 const state = generateState();
79 const codeVerifier = generateCodeVerifier();
80 const codeChallenge = await generateCodeChallenge(codeVerifier);
81
82 saveOAuthState({ state, codeVerifier });
83
84 const parResponse = await fetch("/oauth/par", {
85 method: "POST",
86 headers: { "Content-Type": "application/x-www-form-urlencoded" },
87 body: new URLSearchParams({
88 client_id: CLIENT_ID,
89 redirect_uri: REDIRECT_URI,
90 response_type: "code",
91 scope: SCOPES,
92 state: state,
93 code_challenge: codeChallenge,
94 code_challenge_method: "S256",
95 }),
96 });
97
98 if (!parResponse.ok) {
99 const error = await parResponse.json().catch(() => ({
100 error: "Unknown error",
101 }));
102 throw new Error(
103 error.error_description || error.error || "Failed to start OAuth flow",
104 );
105 }
106
107 const { request_uri } = await parResponse.json();
108
109 const authorizeUrl = new URL("/oauth/authorize", window.location.origin);
110 authorizeUrl.searchParams.set("client_id", CLIENT_ID);
111 authorizeUrl.searchParams.set("request_uri", request_uri);
112
113 window.location.href = authorizeUrl.toString();
114}
115
116export interface OAuthTokens {
117 access_token: string;
118 refresh_token?: string;
119 token_type: string;
120 expires_in?: number;
121 scope?: string;
122 sub: string;
123}
124
125export async function handleOAuthCallback(
126 code: string,
127 state: string,
128): Promise<OAuthTokens> {
129 const savedState = getOAuthState();
130 if (!savedState) {
131 throw new Error("No OAuth state found. Please try logging in again.");
132 }
133
134 if (savedState.state !== state) {
135 clearOAuthState();
136 throw new Error("OAuth state mismatch. Please try logging in again.");
137 }
138
139 const tokenResponse = await fetch("/oauth/token", {
140 method: "POST",
141 headers: { "Content-Type": "application/x-www-form-urlencoded" },
142 body: new URLSearchParams({
143 grant_type: "authorization_code",
144 client_id: CLIENT_ID,
145 code: code,
146 redirect_uri: REDIRECT_URI,
147 code_verifier: savedState.codeVerifier,
148 }),
149 });
150
151 clearOAuthState();
152
153 if (!tokenResponse.ok) {
154 const error = await tokenResponse.json().catch(() => ({
155 error: "Unknown error",
156 }));
157 throw new Error(
158 error.error_description || error.error ||
159 "Failed to exchange code for tokens",
160 );
161 }
162
163 return tokenResponse.json();
164}
165
166export async function refreshOAuthToken(
167 refreshToken: string,
168): Promise<OAuthTokens> {
169 const tokenResponse = await fetch("/oauth/token", {
170 method: "POST",
171 headers: { "Content-Type": "application/x-www-form-urlencoded" },
172 body: new URLSearchParams({
173 grant_type: "refresh_token",
174 client_id: CLIENT_ID,
175 refresh_token: refreshToken,
176 }),
177 });
178
179 if (!tokenResponse.ok) {
180 const error = await tokenResponse.json().catch(() => ({
181 error: "Unknown error",
182 }));
183 throw new Error(
184 error.error_description || error.error || "Failed to refresh token",
185 );
186 }
187
188 return tokenResponse.json();
189}
190
191export function checkForOAuthCallback():
192 | { code: string; state: string }
193 | null {
194 const params = new URLSearchParams(window.location.search);
195 const code = params.get("code");
196 const state = params.get("state");
197
198 if (code && state) {
199 return { code, state };
200 }
201
202 return null;
203}
204
205export function clearOAuthCallbackParams(): void {
206 const url = new URL(window.location.href);
207 url.search = "";
208 window.history.replaceState({}, "", url.toString());
209}