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.