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