AppView in a box as a Vite plugin thing
hatk.dev
1// Browser OAuth client for AT Protocol hatk servers
2//
3// Usage:
4// const client = new OAuthClient({ server: 'https://my-appview.example.com' })
5// await client.login('alice.bsky.social')
6// // ...user approves on PDS...
7// await client.handleCallback()
8// const res = await client.fetch('/xrpc/fm.teal.getFeed?feed=recent')
9
10import { randomString, sha256Base64Url } from './crypto.js'
11import { getOrCreateDPoPKey, createDPoPProof, clearDPoPKey } from './dpop.js'
12import { createStorage, acquireLock, releaseLock } from './storage.js'
13
14const TOKEN_REFRESH_BUFFER_MS = 60_000 // refresh 60s before expiry
15
16export class OAuthClient {
17 /**
18 * @param {object} opts
19 * @param {string} opts.server - Hatk server URL (e.g. 'https://my-appview.example.com')
20 * @param {string} [opts.clientId] - OAuth client_id (defaults to current origin)
21 * @param {string} [opts.redirectUri] - Callback URL (defaults to current page)
22 * @param {string} [opts.scope] - OAuth scope (defaults to 'atproto')
23 */
24 constructor({ server, clientId, redirectUri, scope }) {
25 this.server = server.replace(/\/$/, '')
26 this.clientId = clientId || window.location.origin
27 this.redirectUri = redirectUri || window.location.origin + window.location.pathname
28 this.scope = scope || 'atproto'
29 this.namespace = this.clientId.replace(/[^a-z0-9]/gi, '_').slice(0, 32)
30 this.storage = createStorage(this.namespace)
31 this._initPromise = null
32 }
33
34 /** Ensure DPoP key exists in IndexedDB. */
35 async init() {
36 if (!this._initPromise) {
37 this._initPromise = getOrCreateDPoPKey(this.namespace)
38 }
39 return this._initPromise
40 }
41
42 /** Start the OAuth login flow (redirects the browser). */
43 async login(handle) {
44 await this.init()
45
46 // Generate PKCE
47 const codeVerifier = randomString(32)
48 const codeChallenge = await sha256Base64Url(codeVerifier)
49 const state = randomString(16)
50
51 // Store flow state in sessionStorage
52 this.storage.set('codeVerifier', codeVerifier)
53 this.storage.set('oauthState', state)
54 this.storage.set('clientId', this.clientId)
55 this.storage.set('redirectUri', this.redirectUri)
56
57 // Create DPoP proof for PAR request
58 const parUrl = `${this.server}/oauth/par`
59 const dpopProof = await createDPoPProof(this.namespace, 'POST', parUrl)
60
61 // Send Pushed Authorization Request
62 const parBody = new URLSearchParams({
63 client_id: this.clientId,
64 redirect_uri: this.redirectUri,
65 response_type: 'code',
66 code_challenge: codeChallenge,
67 code_challenge_method: 'S256',
68 scope: this.scope,
69 state,
70 })
71 if (handle) parBody.set('login_hint', handle)
72
73 const parRes = await fetch(parUrl, {
74 method: 'POST',
75 headers: {
76 'Content-Type': 'application/x-www-form-urlencoded',
77 DPoP: dpopProof,
78 },
79 body: parBody.toString(),
80 })
81
82 if (!parRes.ok) {
83 const err = await parRes.json().catch(() => ({}))
84 throw new Error(`PAR failed: ${err.error || parRes.status}`)
85 }
86
87 const { request_uri } = await parRes.json()
88
89 // Redirect to authorize endpoint
90 const authorizeParams = new URLSearchParams({
91 request_uri,
92 client_id: this.clientId,
93 })
94 window.location.href = `${this.server}/oauth/authorize?${authorizeParams}`
95 }
96
97 /**
98 * Handle the OAuth callback after the redirect.
99 * Call this on page load — returns true if a callback was processed.
100 */
101 async handleCallback() {
102 const params = new URLSearchParams(window.location.search)
103 const code = params.get('code')
104 const state = params.get('state')
105 const error = params.get('error')
106
107 if (error) throw new Error(`OAuth error: ${error} - ${params.get('error_description') || ''}`)
108 if (!code || !state) return false
109
110 // Verify state (CSRF)
111 const storedState = this.storage.get('oauthState')
112 if (state !== storedState) throw new Error('OAuth state mismatch')
113
114 const codeVerifier = this.storage.get('codeVerifier')
115 const clientId = this.storage.get('clientId')
116 const redirectUri = this.storage.get('redirectUri')
117 if (!codeVerifier || !clientId || !redirectUri) throw new Error('Missing OAuth session data')
118
119 await this.init()
120
121 // Exchange code for tokens (with DPoP proof)
122 const tokenUrl = `${this.server}/oauth/token`
123 const dpopProof = await createDPoPProof(this.namespace, 'POST', tokenUrl)
124
125 const tokenRes = await fetch(tokenUrl, {
126 method: 'POST',
127 headers: {
128 'Content-Type': 'application/x-www-form-urlencoded',
129 DPoP: dpopProof,
130 },
131 body: new URLSearchParams({
132 grant_type: 'authorization_code',
133 code,
134 redirect_uri: redirectUri,
135 client_id: clientId,
136 code_verifier: codeVerifier,
137 }),
138 })
139
140 if (!tokenRes.ok) {
141 const err = await tokenRes.json().catch(() => ({}))
142 throw new Error(`Token exchange failed: ${err.error_description || tokenRes.statusText}`)
143 }
144
145 const tokens = await tokenRes.json()
146 this._storeTokens(tokens)
147
148 // Clean up flow state
149 this.storage.remove('codeVerifier')
150 this.storage.remove('oauthState')
151 this.storage.remove('redirectUri')
152
153 // Clear URL params
154 window.history.replaceState({}, document.title, window.location.pathname)
155 return true
156 }
157
158 /** Whether the user is currently logged in (has a non-expired token). */
159 get isLoggedIn() {
160 return !!this.storage.get('accessToken') && !!this.storage.get('userDid')
161 }
162
163 /** The logged-in user's DID, or null. */
164 get did() {
165 return this.storage.get('userDid')
166 }
167
168 /** The logged-in user's handle, or null. */
169 get handle() {
170 return this.storage.get('userHandle')
171 }
172
173 /**
174 * Make an authenticated fetch request.
175 * Automatically adds DPoP proof and Authorization header.
176 * Auto-refreshes expired tokens.
177 */
178 async fetch(path, opts = {}) {
179 await this.init()
180
181 const url = path.startsWith('http') ? path : `${this.server}${path}`
182 const method = (opts.method || 'GET').toUpperCase()
183
184 const token = await this._getValidToken()
185 if (!token) throw new Error('Not authenticated')
186
187 const dpopProof = await createDPoPProof(this.namespace, method, url, token)
188
189 const headers = {
190 ...opts.headers,
191 Authorization: `DPoP ${token}`,
192 DPoP: dpopProof,
193 }
194
195 const res = await fetch(url, { ...opts, method, headers })
196
197 // If PDS rejected due to insufficient scope, re-authenticate with current scopes
198 if (res.status === 403) {
199 const body = await res
200 .clone()
201 .json()
202 .catch(() => ({}))
203 if (body.error === 'ScopeMissingError') {
204 this.login(this.did)
205 throw new Error('Re-authenticating with updated scopes')
206 }
207 }
208
209 return res
210 }
211
212 /** Log out — clear all stored tokens and DPoP keys. */
213 async logout() {
214 this.storage.clear()
215 await clearDPoPKey(this.namespace)
216 this._initPromise = null
217 }
218
219 // --- Private ---
220
221 _storeTokens(tokens) {
222 this.storage.set('accessToken', tokens.access_token)
223 if (tokens.refresh_token) this.storage.set('refreshToken', tokens.refresh_token)
224 if (tokens.sub) this.storage.set('userDid', tokens.sub)
225 if (tokens.handle) this.storage.set('userHandle', tokens.handle)
226 const expiresAt = Date.now() + (tokens.expires_in || 3600) * 1000
227 this.storage.set('tokenExpiresAt', expiresAt.toString())
228 }
229
230 async _getValidToken() {
231 const token = this.storage.get('accessToken')
232 const expiresAt = parseInt(this.storage.get('tokenExpiresAt') || '0')
233
234 if (token && Date.now() < expiresAt - TOKEN_REFRESH_BUFFER_MS) {
235 return token
236 }
237
238 // Try to refresh
239 const refreshToken = this.storage.get('refreshToken')
240 if (!refreshToken) return null
241
242 // Multi-tab lock to prevent duplicate refreshes
243 const lockValue = await acquireLock(this.namespace, 'refresh')
244 if (!lockValue) {
245 // Another tab is refreshing — wait and retry
246 await new Promise((r) => setTimeout(r, 150))
247 return this.storage.get('accessToken')
248 }
249
250 try {
251 // Double-check after acquiring lock
252 const fresh = this.storage.get('accessToken')
253 const freshExp = parseInt(this.storage.get('tokenExpiresAt') || '0')
254 if (fresh && Date.now() < freshExp - TOKEN_REFRESH_BUFFER_MS) return fresh
255
256 const tokenUrl = `${this.server}/oauth/token`
257 const dpopProof = await createDPoPProof(this.namespace, 'POST', tokenUrl)
258
259 const res = await fetch(tokenUrl, {
260 method: 'POST',
261 headers: {
262 'Content-Type': 'application/x-www-form-urlencoded',
263 DPoP: dpopProof,
264 },
265 body: new URLSearchParams({
266 grant_type: 'refresh_token',
267 refresh_token: refreshToken,
268 client_id: this.storage.get('clientId') || this.clientId,
269 }),
270 })
271
272 if (!res.ok) {
273 // Refresh failed — clear session
274 this.storage.clear()
275 return null
276 }
277
278 const tokens = await res.json()
279 this._storeTokens(tokens)
280 return tokens.access_token
281 } finally {
282 releaseLock(this.namespace, 'refresh', lockValue)
283 }
284 }
285}