diff --git a/Gemfile b/Gemfile index 5b3e9082..38b46eaf 100644 --- a/Gemfile +++ b/Gemfile @@ -32,6 +32,9 @@ gem 'jbuilder' # Use Redis adapter to run Action Cable in production gem 'redis', '~> 4.0' +# Enable CORS for Chrome extension support +gem 'rack-cors' + # Use Kredis to get higher-level data types in Redis [https://github.com/rails/kredis] # gem "kredis" diff --git a/Gemfile.lock b/Gemfile.lock index 372b847c..6c302074 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -323,6 +323,9 @@ GEM activesupport (>= 3.0.0) racc (1.8.1) rack (3.1.18) + rack-cors (3.0.0) + logger + rack (>= 3.0.14) rack-protection (4.1.1) base64 (>= 0.1.0) logger (>= 1.6.0) @@ -562,6 +565,7 @@ DEPENDENCIES pgvector puma (~> 6.4) pundit (~> 2.3) + rack-cors rails (~> 7.2.0) rails-controller-testing redcarpet diff --git a/app/controllers/auth_controller.rb b/app/controllers/auth_controller.rb new file mode 100644 index 00000000..1969e8fd --- /dev/null +++ b/app/controllers/auth_controller.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +class AuthController < ApplicationController + skip_before_action :verify_authenticity_token, only: %i[get_token validate_token] + skip_before_action :require_login, only: %i[get_token validate_token] + + # GET /auth/get_token + # Return user email for authenticated SSO user (for Chrome extensions) + # The extension's content script reads the token from the page DOM + def get_token + Rails.logger.info '=== AUTH GET_TOKEN CALLED ===' + Rails.logger.info "Session user_id: #{session[:user_id]}" + Rails.logger.info "Current user: #{current_user&.email || 'NOT AUTHENTICATED'}" + Rails.logger.info "Request path: #{request.fullpath}" + + # User is authenticated via SSO session, render page with token + if current_user + Rails.logger.info "✅ User authenticated successfully: #{current_user.email}" + # Render the page with current_user available (extension content script will capture it) + else + Rails.logger.info '❌ User not authenticated, redirecting to login' + # User not authenticated, redirect to login with return URL + redirect_to new_session_path(redirect_to: request.fullpath) + end + rescue StandardError => e + Rails.logger.error "Auth error: #{e.message}" + Rails.logger.error e.backtrace.join("\n") + redirect_to root_path, alert: 'Authentication failed. Please try again.' + end + + # GET /auth/validate + # Validate current token and return user info + def validate_token + if current_user + render json: { + valid: true, + user: { + id: current_user.id, + email: current_user.email + } + } + else + render json: { + valid: false, + error: 'Invalid or expired token' + }, status: :unauthorized + end + end +end diff --git a/app/controllers/saml_controller.rb b/app/controllers/saml_controller.rb index 4f5f4628..9d5f3702 100644 --- a/app/controllers/saml_controller.rb +++ b/app/controllers/saml_controller.rb @@ -3,6 +3,9 @@ class SamlController < ApplicationController skip_before_action :verify_authenticity_token def init + # Store redirect_to parameter in session since SAML redirects lose URL parameters + session[:saml_redirect_to] = params[:redirect_to] if params[:redirect_to].present? + request = OneLogin::RubySaml::Authrequest.new redirect_to(request.create(saml_settings), allow_other_host: true) end @@ -39,11 +42,16 @@ def consume # Setting the session logs the user in. Need to make some methods for this. if user login_user(user) + + # Use stored redirect_to parameter if available, otherwise fallback to root + redirect_url = session[:saml_redirect_to].presence || root_path + session.delete(:saml_redirect_to) # Clean up the session + + redirect_to redirect_url, notice: else notice = 'Login failed. Please contact an admin for help.' + redirect_to root_path, notice: end - - redirect_to root_path, notice: else redirect_to(request.create(saml_settings)) end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 4c305726..357d35eb 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -1,15 +1,29 @@ class SessionsController < ApplicationController skip_before_action :require_login - def new; end + def new + @redirect_to = params[:redirect_to] + end def create user = User.find_by_email(params[:session][:email]) + if user&.authenticate(params[:session][:password]) login_user(user) - redirect_back(fallback_location: root_path) + Rails.logger.info "User logged in: #{user.email}" + + # Use redirect_to parameter if provided, otherwise fallback to previous page or root + if params[:redirect_to].present? + Rails.logger.info "Redirecting to: #{params[:redirect_to]}" + redirect_to params[:redirect_to] + else + redirect_to(root_path) + end else - redirect_to new_session_url, notice: 'Error logging in.' + Rails.logger.info "Login failed for: #{params[:session][:email]}" + # Preserve redirect_to parameter on failed login + redirect_params = params[:redirect_to].present? ? { redirect_to: params[:redirect_to] } : {} + redirect_to new_session_url(redirect_params), notice: 'Error logging in.' end end diff --git a/app/views/auth/get_token.html.erb b/app/views/auth/get_token.html.erb new file mode 100644 index 00000000..44a7e3d6 --- /dev/null +++ b/app/views/auth/get_token.html.erb @@ -0,0 +1,53 @@ +<% content_for :title, "Chrome Extension Token" %> + +
+
+ <% if params[:redirect_uri].present? %> + +
âŸŗ
+

Redirecting...

+

+ Authentication successful! Redirecting back to your extension...
+ This window will close automatically. +

+ <% else %> + +
✓
+

Authentication Successful

+ +

Your user email for the Chrome extension:

+ +
+
+ <%= current_user&.email || 'No email available. Please try again.' %> +
+
+ +
+ Instructions:
+ Your email above will be automatically captured by your Chrome extension.
+ You can safely close this window after the email is captured. +
+ <% end %> + + +
+ Debug: User: <%= current_user&.email || 'NOT AUTHENTICATED' %> | + Session: <%= session[:user_id] || 'NO SESSION' %> | + Redirect URI: <%= params[:redirect_uri].present? ? 'Yes' : 'No' %> +
+
+
+ +<% unless params[:redirect_uri].present? %> + +<% end %> diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb index f0782b84..1dcb4681 100644 --- a/app/views/sessions/new.html.erb +++ b/app/views/sessions/new.html.erb @@ -16,6 +16,9 @@ <% if ENV['DISABLE_PASSWORD_LOGIN'] != "true" %> <%= form_for :session, url: sessions_path, method: :post, class: "mt-8 space-y-6" do |f| %> + <% if @redirect_to.present? %> + + <% end %>
<%= f.label :email, class: "sr-only" %> @@ -41,7 +44,8 @@ <% end %> <% end %>
- <%= link_to "Login with SSO", "/auth/saml/init", class: "group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-sky-800 hover:bg-sky-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-cyan-500" %> + <% sso_url = @redirect_to.present? ? "/auth/saml/init?redirect_to=#{CGI.escape(@redirect_to)}" : "/auth/saml/init" %> + <%= link_to "Login with SSO", sso_url, class: "group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-sky-800 hover:bg-sky-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-cyan-500" %>
\ No newline at end of file diff --git a/chrome-extension/README.md b/chrome-extension/README.md new file mode 100644 index 00000000..0dc4ccfe --- /dev/null +++ b/chrome-extension/README.md @@ -0,0 +1,186 @@ +# AI Chat Assistant Chrome Extension + +A Chrome extension that provides a persistent AI chat interface through a side panel, integrated with your Rails application's SSO authentication. + +## Features + +- 🔐 **SSO Authentication**: Uses your existing SSO/SAML authentication system +- đŸ’Ŧ **AI Chat Interface**: Ask questions and get AI-generated responses in real-time +- 🤔 **Smart Responses**: Powered by your backend questions API with adaptive polling +- 📝 **Chat History**: Clean, scrollable conversation interface with timestamps +- 📌 **Persistent Side Panel**: Always accessible, stays open while browsing (Chrome 114+) +- 💾 **Secure Token Storage**: Stores authentication token securely in Chrome's local storage +- âš™ī¸ **Configurable URLs**: Set custom API and auth URLs for different environments +- 🔄 **Environment Switching**: Easy switching between dev, staging, and production +- 🚀 **Fast Responses**: Optimized polling stops immediately when answers are ready +- đŸŽ¯ **Clean UI**: Simplified interface with logout button and full-height scrolling + +## Installation + +1. **Load the Extension in Chrome:** + - Open Chrome and go to `chrome://extensions/` + - Enable "Developer mode" (toggle in top right) + - Click "Load unpacked" + - Select the `chrome-extension` folder from this project + +2. **Configure API URLs:** + - Open `background.js` + - Update `API_BASE_URL` and `AUTH_BASE_URL` to match your server: + ```javascript + const API_BASE_URL = 'https://your-domain.com/api/v1'; // For production + const AUTH_BASE_URL = 'https://your-domain.com'; // For production + ``` + +## Usage + +### 1. Open Side Panel + +1. Click the extension icon in Chrome to open the persistent side panel +2. The side panel stays open while you browse different websites + +### 2. Authentication + +The extension uses Chrome's secure `chrome.identity.launchWebAuthFlow()` API for authentication: + +1. Configure API URLs if needed (defaults to localhost:3000) +2. Click "🔐 Authenticate with SSO" +3. Complete SSO login in a secure Chrome authentication window +4. Extension automatically captures and stores your API token via redirect +5. Authentication window closes automatically +6. Authentication section disappears, showing clean chat interface + +**Authentication Flow:** +- Extension generates a secure Chrome extension redirect URI +- Opens your Rails SSO login page with the redirect URI +- After successful authentication, Rails redirects to the extension URI with the token +- Chrome automatically captures the token and closes the auth window +- More secure than tab-based authentication (no content script required) + +### 3. Chat with AI + +1. Type your question in the input area +2. Press "🚀 Ask Question" or Ctrl+Enter/Shift+Enter +3. Watch real-time status updates as AI processes your question +4. Get responses with full chat history and timestamps + +### 4. Logout + +- Click the red "Logout" button in the top header to end your session + +## API Endpoints Used + +The extension makes calls to these endpoints: + +- `GET /auth/get_token` - SSO authentication for extensions +- `GET /auth/validate` - Validate current token +- `POST /api/v1/questions` - Submit questions to AI +- `GET /api/v1/questions/{id}` - Get question status and response + +## Security Features + +- **Chrome Identity API**: Uses secure `chrome.identity.launchWebAuthFlow()` for authentication +- **Secure Token Storage**: Uses Chrome's encrypted local storage +- **No Content Script Injection**: Token capture happens via secure redirect (no DOM access needed) +- **Automatic Window Closure**: Auth window closes automatically after token capture +- **Automatic Token Cleanup**: Clears invalid/expired tokens +- **HTTPS Support**: Ready for production HTTPS deployment +- **Legacy Fallback**: Maintains backward compatibility with content script flow + +## Development + +### File Structure +``` +chrome-extension/ +├── manifest.json # Extension manifest with sidePanel configuration +├── background.js # Service worker with API client and side panel handler +├── sidepanel.html # Side panel UI (only interface) +├── sidepanel.js # Side panel functionality and chat logic +├── content.js # Content script for page integration +├── auth-content.js # Auth-specific content script for token capture +├── chrome-version-check.js # Chrome version compatibility check +└── README.md # This file +``` + +### Side Panel Interface + +**📌 Persistent Side Panel (Chrome 114+ Required)** +- Always available - click the extension icon to open/close +- Stays open while you browse different websites and tabs +- Full-height chat scrolling with no container limits +- Clean header with logout button when authenticated +- Collapsible configuration section for easy setup +- No auto-close behavior - stays until you close it manually + +### Chat Features + +The AI chat interface provides: +- **Real-time Status Updates**: See AI processing stages (pending → processing → generating → completed) +- **Adaptive Polling**: Fast polling for quick responses, slower for complex questions +- **Status Indicators**: Visual feedback with timestamps and progress dots +- **Keyboard Shortcuts**: Ctrl+Enter or Shift+Enter to submit questions +- **Auto-scroll**: Chat automatically scrolls to show new messages +- **Clean History**: Timestamped conversation history with user/assistant message styling + +### Customization + +**Adding New Features:** +1. Add API methods to `DocumentAPI` class in `background.js` +2. Add message handlers in the `chrome.runtime.onMessage` listener +3. Add UI controls and handlers in `sidepanel.js` + +**Styling:** +- Modify CSS in `sidepanel.html` ` + + + + + + +
+
🤖 AI Chat Assistant
+ +
+

🔐 Authentication

+
+ +
Not authenticated
+
+
+
+ + +
+

âš™ī¸ Configuration

+
+
+ + +
+ +
+
+
+
+ + +
+

â„šī¸ How to Use

+
+

Welcome to AI Chat Assistant!

+

â€ĸ Click the extension icon to open this side panel

+

â€ĸ The side panel stays open while you browse

+

â€ĸ Configure your API endpoints above, then authenticate

+

â€ĸ Once logged in, you can ask questions and get AI responses

+
+
+
+ + + + + + + diff --git a/chrome-extension/sidepanel.js b/chrome-extension/sidepanel.js new file mode 100644 index 00000000..d05dfda8 --- /dev/null +++ b/chrome-extension/sidepanel.js @@ -0,0 +1,661 @@ +// Side panel script - same functionality as popup but persistent +console.log('Side panel loaded'); + +document.addEventListener('DOMContentLoaded', async () => { + // DOM elements + const loginForm = document.getElementById('loginForm'); + const loginSection = document.getElementById('loginSection'); + const headerBar = document.getElementById('headerBar'); + const chatSection = document.getElementById('chatSection'); + const authError = document.getElementById('authError'); + const authStatus = document.getElementById('authStatus'); + const authBtn = document.getElementById('authBtn'); + const logoutBtn = document.getElementById('logoutBtn'); + + const askBtn = document.getElementById('askBtn'); + const questionInput = document.getElementById('questionInput'); + const chatHistory = document.getElementById('chatHistory'); + const actionStatus = document.getElementById('actionStatus'); + const actionError = document.getElementById('actionError'); + const clearChatBtn = document.getElementById('clearChatBtn'); + + const saveConfigBtn = document.getElementById('saveConfigBtn'); + const baseUrlInput = document.getElementById('baseUrlInput'); + const configStatus = document.getElementById('configStatus'); + const configError = document.getElementById('configError'); + const configToggle = document.getElementById('configToggle'); + const configContent = document.getElementById('configContent'); + + // Load configuration and check authentication status on load + await loadConfiguration(); + await checkAuthStatus(); + + // Check for pending chat text from content scripts + await checkPendingChatText(); + + // Listen for messages from content scripts + chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { + if (request.action === 'addTextToChat') { + addTextFromContentScript(request.text, request.source); + sendResponse({ success: true }); + } + }); + + // Event listeners - with null checks + if (authBtn) authBtn.addEventListener('click', authenticate); + if (logoutBtn) logoutBtn.addEventListener('click', logout); + if (askBtn) askBtn.addEventListener('click', askQuestion); + if (clearChatBtn) clearChatBtn.addEventListener('click', clearChatHistory); + if (saveConfigBtn) saveConfigBtn.addEventListener('click', saveConfiguration); + + // Configuration section toggle + if (configToggle && configContent) { + configToggle.addEventListener('click', () => { + const isExpanded = configContent.classList.contains('expanded'); + if (isExpanded) { + configContent.classList.remove('expanded'); + configContent.classList.add('collapsed'); + configToggle.textContent = '+'; + } else { + configContent.classList.remove('collapsed'); + configContent.classList.add('expanded'); + configToggle.textContent = '−'; + } + }); + } + + // Enter key support for question input (Ctrl+Enter or Shift+Enter to submit) + if (questionInput) { + questionInput.addEventListener('keypress', (e) => { + if ((e.ctrlKey || e.shiftKey) && e.key === 'Enter') { + e.preventDefault(); + askQuestion(); + } + }); + } + + async function checkAuthStatus() { + const validation = await sendMessage({ action: 'validateToken' }); + + if (validation && validation.valid) { + showAuthenticatedState(); + } else { + showUnauthenticatedState(); + } + } + + async function authenticate() { + clearMessages(); + setButtonState(authBtn, 'Authenticating...', true); + authStatus.textContent = 'Opening SSO authentication...'; + + try { + const result = await sendMessage({ action: 'authenticate' }); + + setButtonState(authBtn, '🔐 Authenticate with SSO', false); + + if (result && result.success) { + showAuthenticatedState(); + authStatus.textContent = 'Successfully authenticated!'; + } else { + showUnauthenticatedState(); + authError.textContent = result?.error || 'Authentication failed'; + } + } catch (error) { + setButtonState(authBtn, '🔐 Authenticate with SSO', false); + showUnauthenticatedState(); + authError.textContent = 'Authentication error: ' + error.message; + } + } + + async function logout() { + await sendMessage({ action: 'logout' }); + showUnauthenticatedState(); + } + + async function askQuestion() { + const question = questionInput.value.trim(); + if (!question) { + actionError.textContent = 'Please enter a question'; + return; + } + + clearActionMessages(); + + // Add user message to chat + addUserMessage(question); + + // Clear input + questionInput.value = ''; + + // Disable ask button + askBtn.disabled = true; + askBtn.innerHTML = 'Working...'; + + // Add working message + const workingId = addWorkingMessage(); + + try { + // Get current page context + const pageContext = await getCurrentPageContext(); + + // Append page context to question + const questionWithContext = `${question} + +--- +Context: +Page: ${pageContext.title} +URL: ${pageContext.url}`; + + // Create question + console.log('Creating question with context:', questionWithContext); + const result = await sendMessage({ + action: 'createQuestion', + question: questionWithContext + }); + + console.log('Create question result:', result); + + if (result && result.success) { + console.log('Polling for answer, question ID:', result.data.id); + + // Poll for answer with 2-second polling + const completedQuestion = await sendMessage({ + action: 'getQuestionWithPolling', + id: result.data.id, + maxAttempts: 60, + interval: 3000 + }); + + console.log('Polling result:', completedQuestion); + + if (completedQuestion && completedQuestion.success) { + if (completedQuestion.data.answer) { + updateWorkingMessage(workingId, completedQuestion.data.answer); + } else { + console.log('Question status:', completedQuestion.data.status); + updateWorkingMessage(workingId, `I'm still working on your answer. Status: ${completedQuestion.data.status || 'processing'}`); + } + } else { + const errorMsg = completedQuestion?.error || 'Unknown polling error'; + console.error('Polling failed:', errorMsg); + updateWorkingMessage(workingId, `Sorry, there was an error getting your answer: ${errorMsg}`); + } + } else { + const errorMsg = result?.error || 'Failed to ask question'; + console.error('Create question failed:', errorMsg); + actionError.textContent = errorMsg; + updateWorkingMessage(workingId, `Sorry, I couldn't process your question: ${errorMsg}`); + } + } catch (error) { + console.error('Ask question error:', error); + actionError.textContent = 'Error: ' + error.message; + updateWorkingMessage(workingId, "Sorry, there was an error: " + error.message); + } finally { + // Re-enable ask button + askBtn.disabled = false; + askBtn.innerHTML = '🚀 Ask Question'; + } + } + + function addUserMessage(message) { + const messageDiv = document.createElement('div'); + messageDiv.className = 'chat-message user-message'; + messageDiv.innerHTML = ` +
${escapeHtml(message)}
+
${new Date().toLocaleTimeString()}
+ `; + chatHistory.appendChild(messageDiv); + scrollChatToBottom(); + } + + function addAssistantMessage(message) { + const messageDiv = document.createElement('div'); + messageDiv.className = 'chat-message assistant-message'; + messageDiv.innerHTML = ` +
${formatMarkdown(message)}
+
${new Date().toLocaleTimeString()}
+ `; + chatHistory.appendChild(messageDiv); + scrollChatToBottom(); + } + + function clearChatHistory() { + if (confirm('Are you sure you want to clear the chat history?')) { + chatHistory.innerHTML = ''; + actionStatus.textContent = 'Chat history cleared'; + setTimeout(() => { + actionStatus.textContent = ''; + }, 2000); + } + } + + // Simple markdown formatter + function formatMarkdown(text) { + if (!text) return ''; + + // Escape HTML first to prevent XSS + text = text.replace(/&/g, '&') + .replace(//g, '>'); + + // Code blocks (```code```) + text = text.replace(/```([\s\S]*?)```/g, '
$1
'); + + // Inline code (`code`) + text = text.replace(/`([^`]+)`/g, '$1'); + + // Headers - add markers to split lists later + text = text.replace(/^### (.*$)/gm, '

$1

'); + text = text.replace(/^## (.*$)/gm, '

$1

'); + text = text.replace(/^# (.*$)/gm, '

$1

'); + + // Bold (**bold** or __bold__) + text = text.replace(/\*\*(.*?)\*\*/g, '$1'); + text = text.replace(/__(.*?)__/g, '$1'); + + // Italic (*italic* or _italic_) + text = text.replace(/\*(.*?)\*/g, '$1'); + text = text.replace(/_(.*?)_/g, '$1'); + + // Convert list items but don't wrap in ul/ol yet + text = text.replace(/^[\s]*[-\*\+]\s+(.+)$/gm, '
  • $1
  • '); + text = text.replace(/^[\s]*\d+\.\s+(.+)$/gm, '
  • $1
  • '); + + // Now process the text to create separate lists after headings + text = processListsWithHeadings(text); + + // Links [text](url) and (text)[url] formats + text = text.replace(/\[([^\]]+)\]\(([^\)]+)\)/g, '$1'); + text = text.replace(/\(([^\)]+)\)\[([^\]]+)\]/g, '$1'); + + // Clean up markers + text = text.replace(/|/g, ''); + + // Line breaks + text = text.replace(/\n\n/g, '

    '); + text = text.replace(/\n/g, '
    '); + + // Wrap in paragraphs + if (!text.includes('

    ') && !text.includes('

    ') && !text.includes('

    ') && !text.includes('

    ')) { + text = '

    ' + text + '

    '; + } + + return text; + } + + function processListsWithHeadings(text) { + // Split text by headings to process each section separately + const sections = text.split(/(.*?)/); + + for (let i = 0; i < sections.length; i++) { + // Skip heading sections themselves + if (sections[i].includes('')) continue; + + // Process unordered lists in this section + let section = sections[i]; + const ulItems = section.match(/
  • .*?<\/li>/g); + if (ulItems && ulItems.length > 0) { + const ulList = ''; + section = section.replace(/
  • .*?<\/li>/g, ''); + section = section + ulList; + } + + // Process ordered lists in this section + const olItems = section.match(/
  • .*?<\/li>/g); + if (olItems && olItems.length > 0) { + const olList = '
      ' + olItems.map(item => item.replace('', '')).join('') + '
    '; + section = section.replace(/
  • .*?<\/li>/g, ''); + section = section + olList; + } + + sections[i] = section; + } + + return sections.join(''); + } + + function addWorkingMessage() { + const messageId = 'working-' + Date.now(); + const messageDiv = document.createElement('div'); + messageDiv.id = messageId; + messageDiv.className = 'chat-message assistant-message'; + messageDiv.innerHTML = ` +
    Working...
    +
    ${new Date().toLocaleTimeString()}
    + `; + chatHistory.appendChild(messageDiv); + scrollChatToBottom(); + + return messageId; + } + + function updateWorkingMessage(messageId, answer) { + const message = document.getElementById(messageId); + if (message) { + const contentDiv = message.querySelector('.markdown-content'); + if (contentDiv) { + contentDiv.innerHTML = formatMarkdown(answer); + } + } + } + + function addWelcomeMessage() { + const messageDiv = document.createElement('div'); + messageDiv.className = 'chat-message assistant-message'; + messageDiv.innerHTML = ` +
    👋 Hello! I'm your AI assistant. Ask me any question and I'll do my best to help you.
    +
    ${new Date().toLocaleTimeString()}
    + `; + chatHistory.appendChild(messageDiv); + } + + function scrollChatToBottom() { + // Scroll the whole page to bottom instead of just chat container + window.scrollTo(0, document.body.scrollHeight); + } + + async function checkPendingChatText() { + try { + const result = await chrome.storage.local.get('pendingChatText'); + if (result.pendingChatText) { + const { text, source, timestamp } = result.pendingChatText; + + // Only process if it's recent (within last 5 minutes) + if (Date.now() - timestamp < 300000) { + addTextFromContentScript(text, source); + } + + // Clear the pending text + await chrome.storage.local.remove('pendingChatText'); + } + } catch (error) { + console.error('Error checking pending chat text:', error); + } + } + + function addTextFromContentScript(text, source) { + // Only add if we're authenticated and chat section is visible + if (chatSection && chatSection.style.display !== 'none') { + // Create a special message showing the selected text + const messageDiv = document.createElement('div'); + messageDiv.className = 'chat-message user-message'; + + const sourceUrl = new URL(source); + const sourceDisplay = sourceUrl.hostname + sourceUrl.pathname; + + // Show the selected text as a user message + const truncatedText = text.length > 200 ? text.substring(0, 200) + '...' : text; + + scrollChatToBottom(); + + // Auto-populate the question input with a prompt + if (questionInput) { + const prompt = `Please analyze this selected text:\n\n"${text}"\n\nSource: ${source}`; + questionInput.value = prompt; + questionInput.focus(); + } + } + } + + + async function getAllDocuments() { + await performAction('getDocuments', {}, 'Loading all documents...'); + } + + async function searchDocuments() { + const query = searchInput.value.trim(); + if (!query) { + actionError.textContent = 'Please enter a search query'; + return; + } + + await performAction('searchDocuments', { query }, `Searching for "${query}"...`); + } + + async function findSimilarDocuments() { + const text = similarInput.value.trim(); + if (!text) { + actionError.textContent = 'Please enter text to find similar documents'; + return; + } + + await performAction('getSimilarDocuments', { text }, `Finding similar documents...`); + } + + async function performAction(action, params, statusText) { + clearActionMessages(); + actionStatus.textContent = statusText; + + // Disable all action buttons + const actionButtons = [getAllDocsBtn, searchBtn, similarBtn]; + actionButtons.forEach(btn => btn.disabled = true); + + try { + const result = await sendMessage({ action, ...params }); + + if (result && result.success) { + displayResults(result.data); + actionStatus.textContent = 'Success!'; + } else { + actionError.textContent = result?.error || 'Action failed'; + if (results) results.style.display = 'none'; + } + } catch (error) { + actionError.textContent = 'Error: ' + error.message; + if (results) results.style.display = 'none'; + } finally { + // Re-enable action buttons + actionButtons.forEach(btn => btn.disabled = false); + } + } + + function displayResults(data) { + if (!data) { + if (results) { + results.innerHTML = '
    No data received
    '; + results.style.display = 'block'; + } + return; + } + + // Handle paginated results (common in Rails apps) + const documents = Array.isArray(data) ? data : (data.documents || []); + + if (documents.length === 0) { + if (results) { + results.innerHTML = '
    No documents found
    '; + results.style.display = 'block'; + } + return; + } + + const resultHTML = documents.map(doc => ` +
    +
    ${escapeHtml(doc.title || 'Untitled')}
    +
    + ID: ${doc.id} | + Library: ${escapeHtml(doc.library?.name || 'N/A')} | + Updated: ${formatDate(doc.updated_at)} +
    + ${doc.url ? `` : ''} +
    + `).join(''); + + if (results) { + results.innerHTML = resultHTML; + results.style.display = 'block'; + } + } + + function showAuthenticatedState() { + // Show header bar and hide login section + if (headerBar) headerBar.style.display = 'flex'; + if (loginSection) loginSection.style.display = 'none'; + + if (chatSection) chatSection.style.display = 'block'; + + clearMessages(); + + // Show welcome message if chat is empty + if (chatHistory.children.length === 0) { + addWelcomeMessage(); + } + } + + function showUnauthenticatedState() { + // Hide header bar and show login section + if (headerBar) headerBar.style.display = 'none'; + if (loginSection) loginSection.style.display = 'block'; + + if (chatSection) chatSection.style.display = 'none'; + if (authStatus) authStatus.textContent = 'Not authenticated'; + } + + function setButtonState(button, text, disabled) { + button.textContent = text; + button.disabled = disabled; + } + + function clearMessages() { + if (authError) authError.textContent = ''; + clearActionMessages(); + } + + function clearActionMessages() { + if (actionError) actionError.textContent = ''; + if (actionStatus) actionStatus.textContent = ''; + } + + async function loadConfiguration() { + try { + const result = await sendMessage({ action: 'getConfiguration' }); + if (result && result.success) { + baseUrlInput.value = result.data.baseUrl; + configStatus.textContent = 'Configuration loaded'; + configStatus.style.color = '#666'; + } + } catch (error) { + configError.textContent = 'Failed to load configuration'; + } + } + + async function saveConfiguration() { + const baseUrl = baseUrlInput.value.trim(); + + // Basic validation + if (!baseUrl) { + configError.textContent = 'Base URL is required'; + return; + } + + // Clear previous messages + configError.textContent = ''; + configStatus.textContent = 'Saving...'; + configStatus.style.color = '#666'; + saveConfigBtn.disabled = true; + + try { + const result = await sendMessage({ + action: 'updateConfiguration', + baseUrl: baseUrl + }); + + if (result && result.success) { + configStatus.textContent = '✅ Settings saved successfully!'; + configStatus.style.color = '#28a745'; + + // If user was authenticated, they should re-authenticate with new URL + const validation = await sendMessage({ action: 'validateToken' }); + if (!validation || !validation.valid) { + showUnauthenticatedState(); + authStatus.textContent = 'Please re-authenticate with new URL'; + } + } else { + configError.textContent = result?.error || 'Failed to save settings'; + } + } catch (error) { + configError.textContent = 'Error: ' + error.message; + } finally { + saveConfigBtn.disabled = false; + // Clear success message after 3 seconds + setTimeout(() => { + if (configStatus.textContent.includes('✅')) { + configStatus.textContent = ''; + } + }, 3000); + } + } + + function sendMessage(message) { + return new Promise((resolve) => { + chrome.runtime.sendMessage(message, resolve); + }); + } + + // Get current page context (URL and title) + async function getCurrentPageContext() { + try { + // Query the active tab + const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + if (tabs && tabs.length > 0) { + const activeTab = tabs[0]; + return { + url: activeTab.url || 'Unknown URL', + title: activeTab.title || 'Unknown Title' + }; + } + } catch (error) { + console.warn('Could not get current page context:', error); + } + + // Fallback + return { + url: 'Unknown URL', + title: 'Unknown Title' + }; + } + + function escapeHtml(unsafe) { + if (!unsafe) return ''; + return unsafe + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + } + + function formatDate(dateString) { + if (!dateString) return 'N/A'; + try { + return new Date(dateString).toLocaleDateString(); + } catch (e) { + return dateString; + } + } +}); + +// Auto-refresh authentication status every 30 seconds +setInterval(async () => { + const validation = await new Promise((resolve) => { + chrome.runtime.sendMessage({ action: 'validateToken' }, resolve); + }); + + if (validation && !validation.valid) { + // Token expired, show login form + const loginForm = document.getElementById('loginForm'); + const userInfo = document.getElementById('userInfo'); + const documentSection = document.getElementById('documentSection'); + const authStatus = document.getElementById('authStatus'); + + if (loginForm) loginForm.style.display = 'block'; + if (userInfo) userInfo.style.display = 'none'; + if (documentSection) documentSection.style.display = 'none'; + if (authStatus) authStatus.textContent = 'Session expired - please re-authenticate'; + + // Use current UI structure + showUnauthenticatedState(); + } +}, 30000); + diff --git a/config/initializers/cors.rb b/config/initializers/cors.rb new file mode 100644 index 00000000..21a48b1b --- /dev/null +++ b/config/initializers/cors.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +# Configure CORS for Chrome extension support +Rails.application.config.middleware.insert_before 0, Rack::Cors do + allow do + # Allow Chrome extensions (chrome-extension://) and development origins + origins(/chrome-extension:\/\/.*/, 'http://localhost:3000', 'https://localhost:3000') + + resource '/api/*', + headers: :any, + methods: [:get, :post, :put, :patch, :delete, :options, :head], + credentials: false + end + + # For production, you might want to be more specific about allowed origins + # allow do + # origins 'chrome-extension://your-extension-id-here' + # resource '/api/*', + # headers: :any, + # methods: [:get, :post, :put, :patch, :delete, :options, :head], + # credentials: false + # end +end diff --git a/config/routes.rb b/config/routes.rb index e6230c5c..56d6111a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -25,6 +25,10 @@ get '/sessions/set_beta', to: 'sessions#set_beta' get '/sessions/logout', to: 'sessions#logout', as: :logout + # Chrome Extension SSO Token Generation + get '/auth/get_token', to: 'auth#get_token' + get '/auth/validate', to: 'auth#validate_token' + # SAML Authentication get 'auth/saml/init', to: 'saml#init' post 'auth/saml/consume', to: 'saml#consume' @@ -86,6 +90,8 @@ post 'receive' end end + + # Auth routes moved to main auth controller end end diff --git a/docs/chrome-extension-authentication.md b/docs/chrome-extension-authentication.md new file mode 100644 index 00000000..51b74d61 --- /dev/null +++ b/docs/chrome-extension-authentication.md @@ -0,0 +1,301 @@ +# Chrome Extension Authentication + +This document describes how the Chrome extension authenticates with the Rails API. + +## Overview + +The extension uses a **tab-based authentication flow** with SSO (SAML). This approach: +- ✅ Works in all environments (development, staging, production) +- ✅ Simple and reliable +- ✅ Uses standard Rails SSO authentication +- ✅ No complex OAuth configuration needed + +## Authentication Flow + +``` +1. User clicks "Authenticate with SSO" in extension + ↓ +2. Extension opens /auth/get_token in new tab (background.js) + ↓ +3. If not authenticated → Rails redirects to SSO login + ↓ +4. User completes SSO authentication + ↓ +5. Rails renders auth page with token in DOM (#token-display element) + ↓ +6. Extension's content script (auth-content.js) reads token from page + ↓ +7. Content script sends token to background via chrome.runtime.sendMessage() + ↓ +8. Background script stores token and closes auth tab + ↓ +9. Extension uses token for all API calls +``` + +## Implementation + +### 1. Manifest (manifest.json) + +```json +{ + "permissions": [ + "storage", // Store API token + "tabs", // Create/close auth tabs + "scripting" // Inject content script + ], + "host_permissions": [ + "http://localhost:3000/*" + ], + "optional_host_permissions": [ + "http://*/*", // User can grant access to any HTTP domain + "https://*/*" // User can grant access to any HTTPS domain + ] +} +``` + +**Note:** Optional permissions are requested dynamically when user configures their server URL. + +### 2. Background Script (background.js) + +Opens auth tab and listens for token: + +```javascript +async authenticateWithTab() { + return new Promise((resolve) => { + const authUrl = `${this.baseUrl}/auth/get_token`; + + chrome.tabs.create({ url: authUrl, active: true }, (tab) => { + const authTabId = tab.id; + + // Inject content script when page loads + chrome.tabs.onUpdated.addListener(function listener(tabId, info) { + if (tabId === authTabId && info.status === 'complete') { + chrome.scripting.executeScript({ + target: { tabId: authTabId }, + files: ['auth-content.js'] + }); + } + }); + + // Listen for token message from content script + chrome.runtime.onMessage.addListener((message, sender) => { + if (sender.tab?.id === authTabId && + message.type === 'FACK_AUTH_TOKEN') { + + // Store token + this.token = message.token; + chrome.storage.local.set({ apiToken: this.token }); + + // Close auth tab + chrome.tabs.remove(authTabId); + resolve({ success: true, token: this.token }); + } + }); + }); + }); +} +``` + +### 3. Content Script (auth-content.js) + +Extracts token from page and sends to background: + +```javascript +// Find token in DOM +const tokenDisplay = document.getElementById('token-display'); +const token = tokenDisplay.getAttribute('data-token'); + +// Send to background script +chrome.runtime.sendMessage({ + type: 'FACK_AUTH_TOKEN', + success: true, + token: token +}); +``` + +### 4. Rails Controller (auth_controller.rb) + +```ruby +class AuthController < ApplicationController + skip_before_action :verify_authenticity_token, only: [:get_token] + skip_before_action :require_login, only: [:get_token] + + def get_token + if current_user + # Render page with token (extension reads it from DOM) + else + # Redirect to SSO login + redirect_to new_session_path(redirect_to: request.fullpath) + end + end +end +``` + +### 5. Auth View (get_token.html.erb) + +```erb +
    + <%= current_user&.email %> +
    + + +``` + +## Configuration + +### User Setup + +1. Install extension +2. Open sidepanel → Configuration +3. Enter server URL (e.g., `https://fack.internal.salesforce.com`) +4. Click "Save Settings" → Chrome asks for permission +5. Click "Allow" to grant access to that domain +6. Click "Authenticate with SSO" +7. Complete SSO login +8. Extension captures token automatically + +### Dynamic Permissions + +The extension requests permissions **dynamically** when users configure their server URL: + +```javascript +// When user saves base URL in settings +async updateConfiguration(baseUrl) { + const urlPattern = `${new URL(baseUrl).origin}/*`; + + // Request permission for this URL + const granted = await chrome.permissions.request({ + origins: [urlPattern] + }); + + if (granted) { + this.baseUrl = baseUrl; + await chrome.storage.local.set({ baseUrl }); + } else { + throw new Error('Permission denied'); + } +} +``` + +This allows the extension to work with **any deployment** without hardcoding domains in the manifest. + +## Security Features + +1. **SSO Authentication**: Uses existing enterprise SSO +2. **Dynamic Permissions**: Users explicitly grant access to each domain +3. **Token Storage**: Tokens stored in Chrome's encrypted local storage +4. **Isolated Extension**: Each extension instance has isolated storage +5. **Automatic Cleanup**: Auth tabs close automatically after token capture + +## Development + +### Local Setup + +```bash +# Start Rails server +rails server + +# Load extension +1. Go to chrome://extensions/ +2. Enable "Developer mode" +3. Click "Load unpacked" +4. Select chrome-extension folder + +# Test authentication +1. Open extension sidepanel +2. Base URL should be http://localhost:3000 (default) +3. Click "Authenticate with SSO" +4. Complete login +5. Token captured automatically +``` + +### Console Logs + +**Background console (chrome://extensions → Service Worker):** +``` +🔐 Using tab-based authentication +📑 Auth tab created with ID: 1234567890 +📄 Page loaded in auth tab: http://localhost:3000/auth/get_token +✅ Script injected successfully +📨 Background received message: FACK_AUTH_TOKEN from tab: 1234567890 +✅ Received auth token from correct tab: admin@fack.com +💾 Token saved to storage, closing auth tab +``` + +**Auth page console:** +``` +🔧 Auth content script loaded for: http://localhost:3000/auth/get_token +✅ This is an auth page, will look for token +🔍 Looking for token in page... +✅ Found valid token: admin@fack.com +🚀 Sending token to service worker: admin@fack.com +``` + +## Troubleshooting + +### Token not captured + +**Check:** +1. Is `auth-content.js` injecting? Look for logs in auth page console +2. Does page have `#token-display` element with `data-token` attribute? +3. Is background script receiving the message? Check background console +4. Are tab IDs matching? Compare in logs + +### "Permission denied" when saving URL + +**User needs to:** +1. Click "Allow" when Chrome shows permission dialog +2. If denied, try saving URL again - dialog will reappear +3. Check chrome://extensions for granted permissions + +### Auth window doesn't close + +**Possible causes:** +1. Content script not injecting - check manifest has `scripting` permission +2. Message not reaching background - check tab IDs in logs +3. JavaScript error - check auth page console + +## Production Deployment + +1. Deploy Rails app with SSO configured +2. Users install extension from Chrome Web Store (or load unpacked) +3. Users configure production URL in extension settings +4. Grant permission when prompted +5. Authenticate via SSO + +**No code changes needed** - the extension detects the URL and works automatically! + +## API Usage + +After authentication, all API calls include the token: + +```javascript +async makeAPICall(endpoint, options) { + const response = await fetch(`${this.baseUrl}/api/v1${endpoint}`, { + headers: { + 'Authorization': `Bearer ${this.token}`, + 'Content-Type': 'application/json' + }, + ...options + }); + return response.json(); +} +``` + +## Why This Approach? + +We chose tab-based authentication over `chrome.identity.launchWebAuthFlow()` because: + +✅ **Simpler**: No OAuth configuration needed +✅ **Works everywhere**: Localhost, staging, production +✅ **Easier to debug**: Can inspect auth page like any web page +✅ **Flexible**: Works with any SSO provider +✅ **Open-source friendly**: No hardcoded domains in manifest + +For open-source tools, this approach is ideal since users can point it at any server. + diff --git a/docs/chrome-extension-integration.md b/docs/chrome-extension-integration.md new file mode 100644 index 00000000..e9f52bc7 --- /dev/null +++ b/docs/chrome-extension-integration.md @@ -0,0 +1,497 @@ +# Chrome Extension Integration Guide (DEPRECATED) + +âš ī¸ **This document is outdated.** See [chrome-extension-authentication.md](./chrome-extension-authentication.md) for current implementation. + +--- + +This guide explains how to integrate your Chrome extension with the Rails API authentication system using SSO. + +## Overview + +The Rails API supports Chrome extension authentication through: +- SSO (SAML) authentication with automatic token generation +- API token-based API access +- CORS-enabled endpoints +- Secure token transfer mechanism + +## Authentication Flow + +The Chrome extension authentication uses your existing SSO system: + +1. **Extension requests authentication** → Opens `/auth/get_token` in new tab +2. **SSO authentication** → User completes SAML SSO if not already logged in +3. **Token generation** → Server generates API token for authenticated user +4. **Token display** → User redirected to secure token display page +5. **Token capture** → Extension automatically captures token and closes tab +6. **API access** → Extension uses token for all subsequent API calls + +## API Endpoints + +### Authentication +- `GET /auth/get_token` - SSO authentication and token generation for extensions +- `GET /auth/token_display` - Secure token display page +- `GET /auth/validate` - Validate current token and get user info + +### Data Access +- `GET /api/v1/chats` - List user's chats +- `POST /api/v1/chats` - Create new chat +- `GET /api/v1/chats/:id/messages` - Get messages for a chat +- `POST /api/v1/chats/:id/messages` - Send message to chat +- ... (other endpoints as needed) + +## Chrome Extension Setup + +### 1. Manifest (manifest.json) +```json +{ + "manifest_version": 3, + "name": "Your App Extension", + "version": "1.0", + "description": "Chrome extension for your Rails app", + "permissions": [ + "storage", + "activeTab" + ], + "host_permissions": [ + "http://localhost:3000/*", + "https://your-production-domain.com/*" + ], + "background": { + "service_worker": "background.js" + }, + "content_scripts": [ + { + "matches": [""], + "js": ["content.js"] + } + ], + "action": { + "default_popup": "popup.html", + "default_title": "Your App" + } +} +``` + +### 2. Background Script (background.js) +```javascript +// API configuration +const API_BASE_URL = 'http://localhost:3000/api/v1'; // Change for production +const AUTH_BASE_URL = 'http://localhost:3000'; // Change for production + +class ApiClient { + constructor() { + this.token = null; + this.authWindow = null; + this.init(); + } + + async init() { + // Load token from storage + const result = await chrome.storage.local.get(['apiToken']); + this.token = result.apiToken; + } + + async loginWithSSO() { + return new Promise((resolve) => { + // Open SSO authentication window + const authUrl = `${AUTH_BASE_URL}/auth/get_token`; + + this.authWindow = window.open( + authUrl, + 'sso_auth', + 'width=500,height=600,scrollbars=yes,resizable=yes' + ); + + // Listen for token from auth window + const messageListener = async (event) => { + // Verify origin for security + if (event.origin !== AUTH_BASE_URL.replace(/:\d+$/, '').replace(/^https?:\/\//, 'http://').replace('http://', 'http://') && + event.origin !== AUTH_BASE_URL.replace('http://', 'https://')) { + return; + } + + if (event.data.type === 'FACK_AUTH_TOKEN' && event.data.success) { + // Store token + this.token = event.data.token; + await chrome.storage.local.set({ + apiToken: this.token + }); + + // Notify auth window that token was captured + if (this.authWindow && !this.authWindow.closed) { + this.authWindow.postMessage({ type: 'FACK_TOKEN_CAPTURED' }, '*'); + } + + // Clean up + window.removeEventListener('message', messageListener); + if (this.authWindow) { + this.authWindow.close(); + this.authWindow = null; + } + + // Get user info + try { + const userInfo = await this.validateToken(); + resolve({ success: true, user: userInfo.user }); + } catch (error) { + resolve({ success: true, token: this.token }); + } + } + }; + + // Add message listener + window.addEventListener('message', messageListener); + + // Handle window closed manually + const checkClosed = setInterval(() => { + if (this.authWindow && this.authWindow.closed) { + clearInterval(checkClosed); + window.removeEventListener('message', messageListener); + this.authWindow = null; + resolve({ success: false, error: 'Authentication cancelled' }); + } + }, 1000); + + // Timeout after 5 minutes + setTimeout(() => { + clearInterval(checkClosed); + window.removeEventListener('message', messageListener); + if (this.authWindow && !this.authWindow.closed) { + this.authWindow.close(); + } + this.authWindow = null; + resolve({ success: false, error: 'Authentication timeout' }); + }, 300000); + }); + } + + async validateToken() { + if (!this.token) return { valid: false }; + + try { + const response = await fetch(`${API_BASE_URL}/auth/validate`, { + headers: { + 'Authorization': `Bearer ${this.token}`, + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + + if (!data.valid) { + // Token invalid, clear storage + await this.logout(); + } + + return data; + } catch (error) { + return { valid: false, error: error.message }; + } + } + + async logout() { + if (this.token) { + try { + await fetch(`${API_BASE_URL}/auth/logout`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${this.token}`, + 'Content-Type': 'application/json' + } + }); + } catch (error) { + console.error('Logout error:', error); + } + } + + this.token = null; + await chrome.storage.local.clear(); + } + + async apiCall(endpoint, options = {}) { + if (!this.token) { + throw new Error('Not authenticated'); + } + + const url = `${API_BASE_URL}${endpoint}`; + const defaultOptions = { + headers: { + 'Authorization': `Bearer ${this.token}`, + 'Content-Type': 'application/json', + ...options.headers + } + }; + + const response = await fetch(url, { ...defaultOptions, ...options }); + + if (response.status === 401) { + // Token expired, clear storage + await this.logout(); + throw new Error('Authentication expired'); + } + + return response.json(); + } + + // Convenience methods for common API calls + async getChats() { + return this.apiCall('/chats'); + } + + async createChat(assistantId, message) { + return this.apiCall('/chats', { + method: 'POST', + body: JSON.stringify({ + chat: { + assistant_id: assistantId, + first_message: message + } + }) + }); + } + + async sendMessage(chatId, content) { + return this.apiCall(`/chats/${chatId}/messages`, { + method: 'POST', + body: JSON.stringify({ + message: { content } + }) + }); + } +} + +// Global API client instance +const apiClient = new ApiClient(); + +// Message handling for popup/content scripts +chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { + (async () => { + try { + switch (request.action) { + case 'loginSSO': + const loginResult = await apiClient.loginWithSSO(); + sendResponse(loginResult); + break; + + case 'logout': + await apiClient.logout(); + sendResponse({ success: true }); + break; + + case 'validateToken': + const validation = await apiClient.validateToken(); + sendResponse(validation); + break; + + case 'apiCall': + const result = await apiClient.apiCall(request.endpoint, request.options); + sendResponse({ success: true, data: result }); + break; + + default: + sendResponse({ success: false, error: 'Unknown action' }); + } + } catch (error) { + sendResponse({ success: false, error: error.message }); + } + })(); + + return true; // Keep message channel open for async response +}); +``` + +### 3. Popup HTML (popup.html) +```html + + + + + + + +
    Your App Extension
    + +
    +
    + 🔐 This extension uses your company SSO for secure authentication +
    + + +
    + + + + + + +``` + +### 4. Popup Script (popup.js) +```javascript +document.addEventListener('DOMContentLoaded', async () => { + const loginForm = document.getElementById('loginForm'); + const userInfo = document.getElementById('userInfo'); + const loginError = document.getElementById('loginError'); + const loginStatus = document.getElementById('loginStatus'); + const ssoLoginBtn = document.getElementById('ssoLoginBtn'); + + // Check if already logged in + const validation = await sendMessage({ action: 'validateToken' }); + + if (validation.valid) { + showUserInfo(validation.user); + } else { + showLoginForm(); + } + + // SSO Login button + ssoLoginBtn.addEventListener('click', async () => { + loginError.textContent = ''; + loginStatus.textContent = 'Opening SSO authentication...'; + ssoLoginBtn.disabled = true; + ssoLoginBtn.textContent = 'Authenticating...'; + + const result = await sendMessage({ action: 'loginSSO' }); + + ssoLoginBtn.disabled = false; + ssoLoginBtn.textContent = 'Login with SSO'; + loginStatus.textContent = ''; + + if (result.success) { + showUserInfo(result.user); + } else { + loginError.textContent = result.error || 'SSO authentication failed'; + } + }); + + // Logout button + document.getElementById('logoutBtn').addEventListener('click', async () => { + await sendMessage({ action: 'logout' }); + showLoginForm(); + }); + + function showLoginForm() { + loginForm.style.display = 'block'; + userInfo.style.display = 'none'; + loginError.textContent = ''; + loginStatus.textContent = ''; + ssoLoginBtn.disabled = false; + ssoLoginBtn.textContent = 'Login with SSO'; + } + + function showUserInfo(user) { + loginForm.style.display = 'none'; + userInfo.style.display = 'block'; + document.getElementById('userEmail').textContent = user.email; + document.getElementById('userAdmin').textContent = user.admin ? 'Yes' : 'No'; + } + + function sendMessage(message) { + return new Promise((resolve) => { + chrome.runtime.sendMessage(message, resolve); + }); + } +}); +``` + +## Usage Examples + +### Making API Calls from Content Script +```javascript +// content.js +async function makeApiCall() { + const response = await chrome.runtime.sendMessage({ + action: 'apiCall', + endpoint: '/chats', + options: { method: 'GET' } + }); + + if (response.success) { + console.log('Chats:', response.data); + } else { + console.error('API Error:', response.error); + } +} +``` + +### Creating a New Chat +```javascript +async function createNewChat() { + const response = await chrome.runtime.sendMessage({ + action: 'apiCall', + endpoint: '/chats', + options: { + method: 'POST', + body: JSON.stringify({ + chat: { + assistant_id: 1, // Your assistant ID + first_message: 'Hello from Chrome extension!' + } + }) + } + }); + + if (response.success) { + console.log('Chat created:', response.data); + } +} +``` + +## SSO Authentication Flow Details + +### How It Works + +1. **User clicks "Login with SSO"** in extension popup +2. **Extension opens new tab** to `/auth/get_token` +3. **Server checks authentication:** + - If user already has SSO session → generates token immediately + - If no session → redirects to SAML SSO login +4. **After SSO completion** → server generates API token and redirects to display page +5. **Token display page** → shows token and posts message to extension +6. **Extension captures token** → stores it securely and closes auth tab +7. **Extension uses token** → for all subsequent API calls + +### Security Features + +- **URL Fragment**: Token passed in URL fragment (`#token=...`) for security +- **Auto-cleanup**: Token removed from URL after extraction +- **Origin verification**: Extension verifies message origin +- **Auto-close**: Auth window closes automatically after token capture +- **Token isolation**: Each extension gets its own token + +## Security Considerations + +1. **Token Storage**: API tokens are stored in Chrome's local storage, which is encrypted and isolated per extension +2. **HTTPS**: Always use HTTPS in production +3. **Token Expiration**: Implement token refresh if needed +4. **Extension ID**: In production, consider restricting CORS to specific extension IDs + +## Troubleshooting + +- **CORS Errors**: Check that your API server includes the `rack-cors` gem and proper configuration +- **401 Errors**: Token might be expired or invalid - the extension should handle re-authentication +- **Network Errors**: Verify API endpoint URLs and server availability + +For more details, check the Rails API documentation and Chrome extension development guides.