AppView in a box as a Vite plugin thing hatk.dev
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 285 lines 9.2 kB view raw
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}