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