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}