|
| 1 | +# Chrome Extension Authentication |
| 2 | + |
| 3 | +This document describes how the Chrome extension authenticates with the Rails API. |
| 4 | + |
| 5 | +## Overview |
| 6 | + |
| 7 | +The extension uses a **tab-based authentication flow** with SSO (SAML). This approach: |
| 8 | +- ✅ Works in all environments (development, staging, production) |
| 9 | +- ✅ Simple and reliable |
| 10 | +- ✅ Uses standard Rails SSO authentication |
| 11 | +- ✅ No complex OAuth configuration needed |
| 12 | + |
| 13 | +## Authentication Flow |
| 14 | + |
| 15 | +``` |
| 16 | +1. User clicks "Authenticate with SSO" in extension |
| 17 | + ↓ |
| 18 | +2. Extension opens /auth/get_token in new tab (background.js) |
| 19 | + ↓ |
| 20 | +3. If not authenticated → Rails redirects to SSO login |
| 21 | + ↓ |
| 22 | +4. User completes SSO authentication |
| 23 | + ↓ |
| 24 | +5. Rails renders auth page with token in DOM (#token-display element) |
| 25 | + ↓ |
| 26 | +6. Extension's content script (auth-content.js) reads token from page |
| 27 | + ↓ |
| 28 | +7. Content script sends token to background via chrome.runtime.sendMessage() |
| 29 | + ↓ |
| 30 | +8. Background script stores token and closes auth tab |
| 31 | + ↓ |
| 32 | +9. Extension uses token for all API calls |
| 33 | +``` |
| 34 | + |
| 35 | +## Implementation |
| 36 | + |
| 37 | +### 1. Manifest (manifest.json) |
| 38 | + |
| 39 | +```json |
| 40 | +{ |
| 41 | + "permissions": [ |
| 42 | + "storage", // Store API token |
| 43 | + "tabs", // Create/close auth tabs |
| 44 | + "scripting" // Inject content script |
| 45 | + ], |
| 46 | + "host_permissions": [ |
| 47 | + "http://localhost:3000/*" |
| 48 | + ], |
| 49 | + "optional_host_permissions": [ |
| 50 | + "http://*/*", // User can grant access to any HTTP domain |
| 51 | + "https://*/*" // User can grant access to any HTTPS domain |
| 52 | + ] |
| 53 | +} |
| 54 | +``` |
| 55 | + |
| 56 | +**Note:** Optional permissions are requested dynamically when user configures their server URL. |
| 57 | + |
| 58 | +### 2. Background Script (background.js) |
| 59 | + |
| 60 | +Opens auth tab and listens for token: |
| 61 | + |
| 62 | +```javascript |
| 63 | +async authenticateWithTab() { |
| 64 | + return new Promise((resolve) => { |
| 65 | + const authUrl = `${this.baseUrl}/auth/get_token`; |
| 66 | + |
| 67 | + chrome.tabs.create({ url: authUrl, active: true }, (tab) => { |
| 68 | + const authTabId = tab.id; |
| 69 | + |
| 70 | + // Inject content script when page loads |
| 71 | + chrome.tabs.onUpdated.addListener(function listener(tabId, info) { |
| 72 | + if (tabId === authTabId && info.status === 'complete') { |
| 73 | + chrome.scripting.executeScript({ |
| 74 | + target: { tabId: authTabId }, |
| 75 | + files: ['auth-content.js'] |
| 76 | + }); |
| 77 | + } |
| 78 | + }); |
| 79 | + |
| 80 | + // Listen for token message from content script |
| 81 | + chrome.runtime.onMessage.addListener((message, sender) => { |
| 82 | + if (sender.tab?.id === authTabId && |
| 83 | + message.type === 'FACK_AUTH_TOKEN') { |
| 84 | + |
| 85 | + // Store token |
| 86 | + this.token = message.token; |
| 87 | + chrome.storage.local.set({ apiToken: this.token }); |
| 88 | + |
| 89 | + // Close auth tab |
| 90 | + chrome.tabs.remove(authTabId); |
| 91 | + resolve({ success: true, token: this.token }); |
| 92 | + } |
| 93 | + }); |
| 94 | + }); |
| 95 | + }); |
| 96 | +} |
| 97 | +``` |
| 98 | +
|
| 99 | +### 3. Content Script (auth-content.js) |
| 100 | +
|
| 101 | +Extracts token from page and sends to background: |
| 102 | +
|
| 103 | +```javascript |
| 104 | +// Find token in DOM |
| 105 | +const tokenDisplay = document.getElementById('token-display'); |
| 106 | +const token = tokenDisplay.getAttribute('data-token'); |
| 107 | + |
| 108 | +// Send to background script |
| 109 | +chrome.runtime.sendMessage({ |
| 110 | + type: 'FACK_AUTH_TOKEN', |
| 111 | + success: true, |
| 112 | + token: token |
| 113 | +}); |
| 114 | +``` |
| 115 | +
|
| 116 | +### 4. Rails Controller (auth_controller.rb) |
| 117 | +
|
| 118 | +```ruby |
| 119 | +class AuthController < ApplicationController |
| 120 | + skip_before_action :verify_authenticity_token, only: [:get_token] |
| 121 | + skip_before_action :require_login, only: [:get_token] |
| 122 | + |
| 123 | + def get_token |
| 124 | + if current_user |
| 125 | + # Render page with token (extension reads it from DOM) |
| 126 | + else |
| 127 | + # Redirect to SSO login |
| 128 | + redirect_to new_session_path(redirect_to: request.fullpath) |
| 129 | + end |
| 130 | + end |
| 131 | +end |
| 132 | +``` |
| 133 | +
|
| 134 | +### 5. Auth View (get_token.html.erb) |
| 135 | +
|
| 136 | +```erb |
| 137 | +<div id="token-display" data-token="<%= current_user&.email %>"> |
| 138 | + <%= current_user&.email %> |
| 139 | +</div> |
| 140 | + |
| 141 | +<script> |
| 142 | + // Broadcast token via custom event for content script |
| 143 | + window.dispatchEvent(new CustomEvent('fackAuthToken', { |
| 144 | + detail: { token: '<%= j current_user&.email %>' } |
| 145 | + })); |
| 146 | +</script> |
| 147 | +``` |
| 148 | +
|
| 149 | +## Configuration |
| 150 | +
|
| 151 | +### User Setup |
| 152 | +
|
| 153 | +1. Install extension |
| 154 | +2. Open sidepanel → Configuration |
| 155 | +3. Enter server URL (e.g., `https://fack.internal.salesforce.com`) |
| 156 | +4. Click "Save Settings" → Chrome asks for permission |
| 157 | +5. Click "Allow" to grant access to that domain |
| 158 | +6. Click "Authenticate with SSO" |
| 159 | +7. Complete SSO login |
| 160 | +8. Extension captures token automatically |
| 161 | + |
| 162 | +### Dynamic Permissions |
| 163 | + |
| 164 | +The extension requests permissions **dynamically** when users configure their server URL: |
| 165 | + |
| 166 | +```javascript |
| 167 | +// When user saves base URL in settings |
| 168 | +async updateConfiguration(baseUrl) { |
| 169 | + const urlPattern = `${new URL(baseUrl).origin}/*`; |
| 170 | + |
| 171 | + // Request permission for this URL |
| 172 | + const granted = await chrome.permissions.request({ |
| 173 | + origins: [urlPattern] |
| 174 | + }); |
| 175 | + |
| 176 | + if (granted) { |
| 177 | + this.baseUrl = baseUrl; |
| 178 | + await chrome.storage.local.set({ baseUrl }); |
| 179 | + } else { |
| 180 | + throw new Error('Permission denied'); |
| 181 | + } |
| 182 | +} |
| 183 | +``` |
| 184 | +
|
| 185 | +This allows the extension to work with **any deployment** without hardcoding domains in the manifest. |
| 186 | +
|
| 187 | +## Security Features |
| 188 | +
|
| 189 | +1. **SSO Authentication**: Uses existing enterprise SSO |
| 190 | +2. **Dynamic Permissions**: Users explicitly grant access to each domain |
| 191 | +3. **Token Storage**: Tokens stored in Chrome's encrypted local storage |
| 192 | +4. **Isolated Extension**: Each extension instance has isolated storage |
| 193 | +5. **Automatic Cleanup**: Auth tabs close automatically after token capture |
| 194 | +
|
| 195 | +## Development |
| 196 | +
|
| 197 | +### Local Setup |
| 198 | +
|
| 199 | +```bash |
| 200 | +# Start Rails server |
| 201 | +rails server |
| 202 | +
|
| 203 | +# Load extension |
| 204 | +1. Go to chrome://extensions/ |
| 205 | +2. Enable "Developer mode" |
| 206 | +3. Click "Load unpacked" |
| 207 | +4. Select chrome-extension folder |
| 208 | +
|
| 209 | +# Test authentication |
| 210 | +1. Open extension sidepanel |
| 211 | +2. Base URL should be http://localhost:3000 (default) |
| 212 | +3. Click "Authenticate with SSO" |
| 213 | +4. Complete login |
| 214 | +5. Token captured automatically |
| 215 | +``` |
| 216 | +
|
| 217 | +### Console Logs |
| 218 | +
|
| 219 | +**Background console (chrome://extensions → Service Worker):** |
| 220 | +``` |
| 221 | +🔐 Using tab-based authentication |
| 222 | +📑 Auth tab created with ID: 1234567890 |
| 223 | +📄 Page loaded in auth tab: http://localhost:3000/auth/get_token |
| 224 | +✅ Script injected successfully |
| 225 | +📨 Background received message: FACK_AUTH_TOKEN from tab: 1234567890 |
| 226 | +✅ Received auth token from correct tab: [email protected] |
| 227 | +💾 Token saved to storage, closing auth tab |
| 228 | +``` |
| 229 | +
|
| 230 | +**Auth page console:** |
| 231 | +``` |
| 232 | +🔧 Auth content script loaded for: http://localhost:3000/auth/get_token |
| 233 | +✅ This is an auth page, will look for token |
| 234 | +🔍 Looking for token in page... |
| 235 | +✅ Found valid token: [email protected] |
| 236 | +🚀 Sending token to service worker: [email protected] |
| 237 | +``` |
| 238 | +
|
| 239 | +## Troubleshooting |
| 240 | +
|
| 241 | +### Token not captured |
| 242 | +
|
| 243 | +**Check:** |
| 244 | +1. Is `auth-content.js` injecting? Look for logs in auth page console |
| 245 | +2. Does page have `#token-display` element with `data-token` attribute? |
| 246 | +3. Is background script receiving the message? Check background console |
| 247 | +4. Are tab IDs matching? Compare in logs |
| 248 | +
|
| 249 | +### "Permission denied" when saving URL |
| 250 | +
|
| 251 | +**User needs to:** |
| 252 | +1. Click "Allow" when Chrome shows permission dialog |
| 253 | +2. If denied, try saving URL again - dialog will reappear |
| 254 | +3. Check chrome://extensions for granted permissions |
| 255 | +
|
| 256 | +### Auth window doesn't close |
| 257 | +
|
| 258 | +**Possible causes:** |
| 259 | +1. Content script not injecting - check manifest has `scripting` permission |
| 260 | +2. Message not reaching background - check tab IDs in logs |
| 261 | +3. JavaScript error - check auth page console |
| 262 | +
|
| 263 | +## Production Deployment |
| 264 | +
|
| 265 | +1. Deploy Rails app with SSO configured |
| 266 | +2. Users install extension from Chrome Web Store (or load unpacked) |
| 267 | +3. Users configure production URL in extension settings |
| 268 | +4. Grant permission when prompted |
| 269 | +5. Authenticate via SSO |
| 270 | +
|
| 271 | +**No code changes needed** - the extension detects the URL and works automatically! |
| 272 | +
|
| 273 | +## API Usage |
| 274 | +
|
| 275 | +After authentication, all API calls include the token: |
| 276 | +
|
| 277 | +```javascript |
| 278 | +async makeAPICall(endpoint, options) { |
| 279 | + const response = await fetch(`${this.baseUrl}/api/v1${endpoint}`, { |
| 280 | + headers: { |
| 281 | + 'Authorization': `Bearer ${this.token}`, |
| 282 | + 'Content-Type': 'application/json' |
| 283 | + }, |
| 284 | + ...options |
| 285 | + }); |
| 286 | + return response.json(); |
| 287 | +} |
| 288 | +``` |
| 289 | +
|
| 290 | +## Why This Approach? |
| 291 | +
|
| 292 | +We chose tab-based authentication over `chrome.identity.launchWebAuthFlow()` because: |
| 293 | +
|
| 294 | +✅ **Simpler**: No OAuth configuration needed |
| 295 | +✅ **Works everywhere**: Localhost, staging, production |
| 296 | +✅ **Easier to debug**: Can inspect auth page like any web page |
| 297 | +✅ **Flexible**: Works with any SSO provider |
| 298 | +✅ **Open-source friendly**: No hardcoded domains in manifest |
| 299 | +
|
| 300 | +For open-source tools, this approach is ideal since users can point it at any server. |
| 301 | +
|
0 commit comments