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: 'atproto transition:generic',
79 state: state,
80 code_challenge: codeChallenge,
81 code_challenge_method: 'S256',
82 }),
83 })
84
85 if (!parResponse.ok) {
86 const error = await parResponse.json().catch(() => ({ error: 'Unknown error' }))
87 throw new Error(error.error_description || error.error || 'Failed to start OAuth flow')
88 }
89
90 const { request_uri } = await parResponse.json()
91
92 const authorizeUrl = new URL('/oauth/authorize', window.location.origin)
93 authorizeUrl.searchParams.set('client_id', clientId)
94 authorizeUrl.searchParams.set('request_uri', request_uri)
95
96 window.location.href = authorizeUrl.toString()
97}
98
99export interface OAuthTokens {
100 access_token: string
101 refresh_token?: string
102 token_type: string
103 expires_in?: number
104 scope?: string
105 sub: string
106}
107
108export async function handleOAuthCallback(code: string, state: string): Promise<OAuthTokens> {
109 const savedState = getOAuthState()
110 if (!savedState) {
111 throw new Error('No OAuth state found. Please try logging in again.')
112 }
113
114 if (savedState.state !== state) {
115 clearOAuthState()
116 throw new Error('OAuth state mismatch. Please try logging in again.')
117 }
118
119 const clientId = `${window.location.origin}/oauth/client-metadata.json`
120 const redirectUri = `${window.location.origin}/`
121
122 const tokenResponse = await fetch('/oauth/token', {
123 method: 'POST',
124 headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
125 body: new URLSearchParams({
126 grant_type: 'authorization_code',
127 client_id: clientId,
128 code: code,
129 redirect_uri: redirectUri,
130 code_verifier: savedState.codeVerifier,
131 }),
132 })
133
134 clearOAuthState()
135
136 if (!tokenResponse.ok) {
137 const error = await tokenResponse.json().catch(() => ({ error: 'Unknown error' }))
138 throw new Error(error.error_description || error.error || 'Failed to exchange code for tokens')
139 }
140
141 return tokenResponse.json()
142}
143
144export async function refreshOAuthToken(refreshToken: string): Promise<OAuthTokens> {
145 const clientId = `${window.location.origin}/oauth/client-metadata.json`
146
147 const tokenResponse = await fetch('/oauth/token', {
148 method: 'POST',
149 headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
150 body: new URLSearchParams({
151 grant_type: 'refresh_token',
152 client_id: clientId,
153 refresh_token: refreshToken,
154 }),
155 })
156
157 if (!tokenResponse.ok) {
158 const error = await tokenResponse.json().catch(() => ({ error: 'Unknown error' }))
159 throw new Error(error.error_description || error.error || 'Failed to refresh token')
160 }
161
162 return tokenResponse.json()
163}
164
165export function checkForOAuthCallback(): { code: string; state: string } | null {
166 const params = new URLSearchParams(window.location.search)
167 const code = params.get('code')
168 const state = params.get('state')
169
170 if (code && state) {
171 return { code, state }
172 }
173
174 return null
175}
176
177export function clearOAuthCallbackParams(): void {
178 const url = new URL(window.location.href)
179 url.search = ''
180 window.history.replaceState({}, '', url.toString())
181}