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}