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