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
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}