A social knowledge tool for researchers built on ATProto
1# Browser Extension Authentication Guide 2 3Authenticating a browser extension with a backend service requires a different approach than traditional web applications that rely on HttpOnly session cookies. Due to the extension's unique origin (`chrome-extension://your-extension-id` or similar), a token-based authentication flow is generally more robust and secure. 4 5This guide outlines the steps to implement such a flow. 6 7## 1. Shift to Token-Based Authentication 8 9Instead of session cookies, your extension will manage authentication tokens explicitly: 10 11- **Access Token:** A short-lived token used to authorize API requests. 12- **Refresh Token:** A long-lived token used to obtain a new access token when the current one expires, avoiding frequent re-logins. 13 14Your backend will issue these tokens upon successful user authentication. 15 16## 2. Authentication Initiation and Token Acquisition 17 18The extension initiates the login process, typically via an OAuth 2.0 flow. 19 20- **Login Trigger:** A user action (e.g., clicking a "Login" button) in the extension's UI (popup or options page). 21- **OAuth Flow with `launchWebAuthFlow`:** 22 - Use `chrome.identity.launchWebAuthFlow` (or `browser.identity.launchWebAuthFlow` for Firefox/other browsers supporting the WebExtensions API). This API is specifically designed for extensions to handle web-based authentication flows. 23 - **Invocation:** 24 25 ```javascript 26 // In your extension's background script or popup script 27 28 // 1. Construct the backend URL that will initiate the OAuth flow with the PDS. 29 // This backend URL should specify the *extension's* callback URL as a parameter 30 // so the backend knows where to redirect after PDS authentication. 31 // The backend's /login route (or a new one like /oauth/extension/initiate) 32 // will then redirect to the PDS, including its own /oauth/extension/callback 33 // as the redirect_uri for the PDS. 34 35 // Example: The extension wants to be called back at `extensionCallbackUrl`. 36 // The backend has an endpoint `/login` which takes `final_redirect_uri` for the extension. 37 const pdsHandle = 'bsky.social'; // Or user-provided 38 const backendLoginUrl = `https://your-backend.com/login?handle=${encodeURIComponent(pdsHandle)}&target_link_uri=${encodeURIComponent(chrome.identity.getRedirectURL('callback'))}`; 39 // Note: The backend's /login route would need modification to accept `target_link_uri` 40 // and pass it through its own PDS authorization call, or use a dedicated initiation endpoint. 41 // For simplicity, the current /login route in `src/routes.ts` doesn't support this `target_link_uri` directly for extension flow. 42 // A more direct approach for the extension might be to construct the PDS auth URL itself, 43 // ensuring the `redirect_uri` for the PDS is `https://your-backend.com/oauth/extension/callback`. 44 45 // Assuming the backend's `/login` or a similar endpoint initiates the PDS OAuth flow 46 // and is configured to use `https://your-backend.com/oauth/extension/callback` as its redirect_uri with the PDS. 47 // The extension then calls `launchWebAuthFlow` targeting the PDS directly or via a backend initiator. 48 // For this example, let's assume the extension calls a backend endpoint that starts the flow. 49 // The backend's `oauthClient.authorize()` in `src/routes.ts` (e.g., in POST /login) 50 // will use the `redirect_uris` configured in `src/auth/client.ts`, one of which is 51 // for `/oauth/extension/callback`. 52 53 const backendInitiationUrl = `https://your-backend.com/login`; // This needs to be a POST or handle selection. 54 // Or a dedicated GET endpoint for extension. 55 56 // A more direct PDS URL construction (if client_id is known or discoverable): 57 // const clientId = `https://your-backend.com/client-metadata.json`; 58 // const pdsAuthUrl = `https://${pdsHandle}/oauth/authorize?client_id=${encodeURIComponent(clientId)}&response_type=code&redirect_uri=${encodeURIComponent('https://your-backend.com/oauth/extension/callback')}&scope=atproto%20transition%3Ageneric&state=YOUR_STATE_HERE`; 59 60 // For this example, let's assume `backendInitiationUrl` correctly starts the flow 61 // leading to `https://your-backend.com/oauth/extension/callback` 62 chrome.identity.launchWebAuthFlow( 63 { 64 url: authUrl, 65 interactive: true, // Prompts the user for login if necessary 66 }, 67 (callbackUrl) => { 68 if (chrome.runtime.lastError || !callbackUrl) { 69 console.error( 70 'Authentication failed:', 71 chrome.runtime.lastError?.message, 72 ); 73 // Handle authentication error (e.g., user cancelled) 74 return; 75 } 76 // Example callbackUrl: https://<your-extension-id>.chromiumapp.org/callback#access_token=XYZ&refresh_token=ABC&expires_in=3600 77 // Or query params: https://<your-extension-id>.chromiumapp.org/callback?code=AUTH_CODE 78 // The format depends on how your backend/OAuth provider returns tokens/codes. 79 extractTokensFromCallback(callbackUrl); 80 }, 81 ); 82 ``` 83 84 - **Redirect URI Flow:** 85 1. The extension initiates the OAuth flow using `chrome.identity.launchWebAuthFlow`. The URL provided to `launchWebAuthFlow` should ultimately lead to the PDS authorization endpoint. 86 2. The PDS is configured (via your backend's client registration) to redirect to your backend's specific extension callback: `https://your-backend.com/oauth/extension/callback`. 87 3. Your backend's `/oauth/extension/callback` (in `src/routes.ts`) handles the code exchange with the PDS, retrieves the PDS access/refresh tokens, and stores the PDS session details (including refresh token) in its `SessionStore` (linked to the user's DID). 88 4. Crucially, this backend endpoint then redirects _back to the extension's internal callback URL_ (e.g., `https://<extension-id>.chromiumapp.org/callback` obtained via `chrome.identity.getRedirectURL("callback")`). This redirect includes the PDS access token, refresh token, DID, and expiry information in the URL fragment (`#`). 89 - **Token Extraction by Extension:** `launchWebAuthFlow` captures this final redirect to the extension's internal callback. Your extension code then parses the tokens and other details from the URL fragment. 90 91 ```javascript 92 // In the callback function of launchWebAuthFlow 93 function extractTokensFromCallback(callbackUrlString) { 94 const url = new URL(callbackUrlString); 95 const params = new URLSearchParams(url.hash.substring(1)); // Remove '#' 96 const accessToken = params.get('access_token'); 97 const refreshToken = params.get('refresh_token'); 98 const did = params.get('did'); 99 const expiresIn = params.get('expires_in'); // String, in seconds 100 101 if (accessToken && did) { 102 saveTokens(accessToken, refreshToken, did, expiresIn); 103 } else { 104 console.error( 105 'Failed to extract tokens from callback URL:', 106 callbackUrlString, 107 ); 108 // Handle error 109 } 110 } 111 ``` 112 113## 3. Persistent Tokens and Data Storage 114 115Store tokens securely and persistently within the extension. 116 117- **Tokens to Persist:** 118 - Access Token 119 - Refresh Token 120 - User's DID (Decentralized Identifier) or other relevant user information. 121- **Storage Mechanism:** Use `chrome.storage.local` (or `browser.storage.local`). This is an asynchronous API designed for extension data storage. 122 123 ```javascript 124 // Storing tokens after successful extraction 125 function saveTokens(accessToken, refreshToken, userDid, expiresInString) { 126 const expiresIn = expiresInString ? parseInt(expiresInString, 10) : 3600; // Default to 1 hour if not provided 127 chrome.storage.local.set( 128 { 129 accessToken: accessToken, 130 refreshToken: refreshToken, // May be null or empty if PDS doesn't return it directly here 131 userDid: userDid, 132 tokenExpiry: Date.now() + expiresIn * 1000, 133 }, 134 () => { 135 console.log('Tokens and user DID stored.'); 136 // Update UI, enable authenticated features 137 }, 138 ); 139 } 140 141 // Retrieving tokens 142 chrome.storage.local.get( 143 ['accessToken', 'refreshToken', 'userDid'], 144 (result) => { 145 if (result.accessToken) { 146 // User is likely logged in 147 } 148 }, 149 ); 150 ``` 151 152## 4. Making Authenticated API Calls 153 154Include the access token in the `Authorization` header for requests to your protected backend APIs. 155 156```javascript 157async function makeAuthenticatedRequest(url, options = {}) { 158 return new Promise((resolve, reject) => { 159 chrome.storage.local.get(['accessToken', 'tokenExpiry'], async (result) => { 160 if (!result.accessToken) { 161 return reject(new Error('Not authenticated. No access token found.')); 162 } 163 164 // Optional: Proactive token refresh if expiry is near 165 // if (result.tokenExpiry && Date.now() >= result.tokenExpiry - (5 * 60 * 1000) /* 5 mins buffer */) { 166 // try { 167 // await refreshAccessToken(); // Implement this function (see section 5) 168 // // After refresh, get the new token 169 // chrome.storage.local.get(['accessToken'], (refreshedResult) => { 170 // if (!refreshedResult.accessToken) return reject(new Error('Token refresh failed.')); 171 // sendRequest(url, options, refreshedResult.accessToken, resolve, reject); 172 // }); 173 // return; 174 // } catch (refreshError) { 175 // return reject(refreshError); 176 // } 177 // } 178 179 sendRequest(url, options, result.accessToken, resolve, reject); 180 }); 181 }); 182} 183 184async function sendRequest(url, options, token, resolve, reject) { 185 try { 186 const response = await fetch(url, { 187 ...options, 188 headers: { 189 ...options.headers, 190 'Content-Type': 'application/json', // Or other appropriate content type 191 Authorization: `Bearer ${token}`, 192 }, 193 }); 194 195 if (response.status === 401) { 196 // Access token might be expired or invalid 197 // Attempt to refresh the token (see section 5) 198 // For simplicity, here we just reject. A robust implementation would trigger refresh. 199 return reject(new Error('Unauthorized. Token may be expired.')); 200 } 201 if (!response.ok) { 202 const errorData = await response.text(); 203 return reject( 204 new Error(`API request failed: ${response.status} ${errorData}`), 205 ); 206 } 207 resolve(await response.json()); // Or response.text(), etc. 208 } catch (error) { 209 reject(error); 210 } 211} 212 213// Usage: 214// makeAuthenticatedRequest('https://your-backend.com/api/status', { 215// method: 'POST', 216// body: JSON.stringify({ status: 'Hello from extension!' }) 217// }) 218// .then(data => console.log('Status posted:', data)) 219// .catch(error => console.error('Error posting status:', error)); 220``` 221 222## 5. Token Refresh 223 224Access tokens are short-lived. Implement a mechanism to use the refresh token to obtain a new access token when the current one expires. 225 226- **Trigger:** An API call fails with a 401 Unauthorized status. 227- **Process:** 228 1. Retrieve the refresh token from `chrome.storage.local`. 229 2. Make a request to your backend's token refresh endpoint (e.g., `/oauth/refresh`), sending the refresh token. 230 3. Your backend validates the refresh token and, if valid, issues a new access token (and potentially a new refresh token). 231 4. Store the new token(s) in `chrome.storage.local`. 232 5. Retry the original API request that failed. 233 234```javascript 235async function refreshAccessToken() { 236 return new Promise((resolve, reject) => { 237 chrome.storage.local.get(['refreshToken', 'userDid'], async (result) => { 238 if (!result.refreshToken || !result.userDid) { 239 // The refresh token might have been initially empty if the PDS didn't return it directly 240 // to the extension. The backend's SessionStore holds the authoritative refresh token. 241 // The extension needs the DID to ask the backend to use its stored refresh token. 242 return reject( 243 new Error( 244 'No refresh token or user DID available for refresh. User may need to re-authenticate.', 245 ), 246 ); 247 } 248 249 try { 250 // The extension sends its stored PDS refresh token (if it has one) AND the user's DID. 251 // The backend's /oauth/refresh endpoint will primarily use the DID to look up 252 // the authoritative PDS session (including refresh token) from its SessionStore. 253 const response = await fetch('https://your-backend.com/oauth/refresh', { 254 method: 'POST', 255 headers: { 'Content-Type': 'application/json' }, 256 body: JSON.stringify({ 257 refreshToken: result.refreshToken, 258 did: result.userDid, 259 }), 260 }); 261 262 if (!response.ok) { 263 // Refresh failed (e.g., refresh token expired or revoked) 264 // Log out the user 265 await handleLogout(); 266 return reject(new Error('Failed to refresh access token.')); 267 } 268 269 const { 270 accessToken, 271 refreshToken: newRefreshToken, 272 expiresIn, 273 } = await response.json(); 274 // Update stored tokens 275 const updatedTokens = { accessToken }; 276 if (newRefreshToken) updatedTokens.refreshToken = newRefreshToken; // If backend rotates refresh tokens 277 if (expiresIn) 278 updatedTokens.tokenExpiry = Date.now() + expiresIn * 1000; 279 280 chrome.storage.local.set(updatedTokens, () => { 281 console.log('Access token refreshed.'); 282 resolve(); 283 }); 284 } catch (error) { 285 console.error('Error refreshing token:', error); 286 reject(error); 287 } 288 }); 289 }); 290} 291``` 292 293**Note:** Implement a queueing mechanism for concurrent API calls if a token refresh is in progress to avoid multiple refresh attempts. 294 295## 6. Logout 296 297Clear stored tokens and any other user-specific data. 298 299```javascript 300async function handleLogout() { 301 // Optional: Inform the backend to invalidate the refresh token, if supported 302 // try { 303 // chrome.storage.local.get(['refreshToken'], async (result) => { 304 // if (result.refreshToken) { 305 // await fetch('https://your-backend.com/oauth/revoke', { 306 // method: 'POST', 307 // headers: { 'Content-Type': 'application/json' }, 308 // body: JSON.stringify({ token: result.refreshToken, token_type_hint: 'refresh_token' }) 309 // }); 310 // } 311 // }); 312 // } catch (error) { 313 // console.warn('Failed to revoke token on backend during logout:', error); 314 // } 315 316 chrome.storage.local.remove( 317 ['accessToken', 'refreshToken', 'userDid', 'tokenExpiry'], 318 () => { 319 console.log('User logged out, tokens removed.'); 320 // Update UI to reflect logged-out state 321 }, 322 ); 323} 324``` 325 326## 7. Backend Adjustments 327 328Your backend will need to: 329 330- **OAuth Client Configuration (`src/auth/client.ts`):** 331 - The `redirect_uris` array in `NodeOAuthClient` configuration must include your backend's specific callback URL for extensions (e.g., `https://your-backend.com/oauth/extension/callback`). 332- **Extension OAuth Callback (`/oauth/extension/callback` in `src/routes.ts`):** 333 - This new backend route handles the redirect from the PDS after successful user authentication. 334 - It uses `ctx.oauthClient.callback(params)` to exchange the authorization code for PDS tokens (access and refresh). 335 - Crucially, `oauthClient.callback()` also saves the full PDS session (including the refresh token) into the backend's `SessionStore`, associated with the user's DID. This is vital for later token refreshes initiated by the backend. 336 - Instead of setting an `iron-session` cookie (like the web flow), this endpoint redirects the user's browser (within the `launchWebAuthFlow` popup) to the extension's internal callback URL (e.g., `https://<extension-id>.chromiumapp.org/callback`). 337 - This redirect includes the PDS `access_token`, `refresh_token` (if provided by PDS), `did`, `expires_in`, etc., in the URL fragment (`#`), for the extension to parse. 338- **Bearer Token Authentication (`getSessionAgent` in `src/routes.ts`):** 339 - The `getSessionAgent` function (or a similar middleware for authenticated routes) is modified to check for an `Authorization: Bearer <token>` header. 340 - If a Bearer token (which is the PDS access token sent by the extension) is present: 341 - The backend should ideally decode and validate this JWT to extract the user's `did` (e.g., from the `sub` claim). This requires a JWT library like `jose`. (Currently, this part is a placeholder in `src/routes.ts` and needs full implementation). 342 - Once the `did` is obtained, `ctx.oauthClient.restore(did)` is called. This function leverages the `SessionStore` (where the PDS refresh token is stored) to obtain a valid AT Protocol `Agent`. If the PDS access token provided by the extension is expired, `restore(did)` will automatically attempt to use the stored PDS refresh token to get a new PDS access token. 343- **Token Refresh Endpoint (`/oauth/refresh` in `src/routes.ts`):** 344 - This new `POST` endpoint allows the extension to request a new PDS access token. 345 - The extension sends its stored PDS `refreshToken` (if it has one) and the `did`. 346 - The backend uses the `did` to call `ctx.oauthClient.sessionStore.refresh(did, true)`. This method uses the authoritative refresh token stored in the backend's `SessionStore` for that DID to get a new PDS access token. 347 - The new PDS `access_token` and its `expires_in` are returned to the extension. 348- **CORS Configuration:** 349 - The backend must be configured with Cross-Origin Resource Sharing (CORS) middleware to allow requests from your extension's origin (e.g., `chrome-extension://<your-extension-id>`). This is typically set up in `src/index.ts`. 350- **Environment Variables:** 351 - An environment variable like `EXTENSION_ID` (used in `src/routes.ts` for constructing the extension's redirect URI) needs to be configured. 352 353By following these steps, and implementing the necessary JWT validation, you can create a secure and robust authentication system for your browser extension, leveraging the backend's capability to manage PDS refresh tokens effectively.