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