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}