diff --git a/docs/AUTH-FLOW-DOCUMENTATION.md b/docs/AUTH-FLOW-DOCUMENTATION.md new file mode 100644 index 0000000..3471e23 --- /dev/null +++ b/docs/AUTH-FLOW-DOCUMENTATION.md @@ -0,0 +1,767 @@ +# OpenExcel Authentication Flow Documentation + +**Version:** 1.0 +**Last Updated:** April 20, 2026 +**Status:** Current, Implemented & Production-Ready + +--- + +## Executive Summary + +The **OpenExcel** application implements a **client-side OAuth 2.0 authentication flow with PKCE (Proof Key for Code Exchange)** to securely obtain and manage access tokens for LLM providers. The architecture follows a **BYOK (Bring Your Own Key)** model where users authenticate directly with their chosen OAuth provider (Anthropic Claude, OpenAI ChatGPT) and the application manages token lifecycle entirely on the client-side. + +### Key Characteristics +- **Protocol:** OAuth 2.0 with PKCE (public client, browser-based) +- **Supported Providers:** Anthropic Claude Pro/Max, OpenAI ChatGPT Plus/Pro +- **Token Storage:** Browser localStorage + IndexedDB (scoped by document/workbook) +- **Token Refresh:** Proactive (checked before each LLM API call) +- **Architecture:** Client-side only (no backend OAuth relay required) +- **Security Model:** PKCE for preventing authorization code interception in redirects + +--- + +## Architecture Overview + +### High-Level Components + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ USER BROWSER/EXCEL │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ Svelte Settings Panel (settings-panel.svelte) │ │ +│ │ - OAuth provider selection │ │ +│ │ - Login/logout UX │ │ +│ │ - Token status display │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ┌────────────────────────▼─────────────────────────────────┐ │ +│ │ Client Storage (localStorage + IndexedDB) │ │ +│ │ - OAuth credentials: {refresh, access, expires} │ │ +│ │ - Provider config: provider, model, authMethod │ │ +│ │ - Session data: chat messages, VFS files, skills │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ Agent Runtime (runtime.ts) │ │ +│ │ - getActiveApiKey(): Check token expiry & refresh │ │ +│ │ - Stream LLM messages with current access token │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ OAuth Handler (packages/sdk/src/oauth/index.ts) │ │ +│ │ - generatePKCE(): Create code challenge/verifier │ │ +│ │ - buildAuthorizationUrl(): Generate OAuth redirect │ │ +│ │ - exchangeOAuthCode(): Token endpoint request │ │ +│ │ - refreshOAuthToken(): Refresh token endpoint │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ + │ + ┌──────────────────┼──────────────────┐ + │ │ │ + ▼ ▼ ▼ + ┌─────────┐ ┌──────────────┐ ┌────────────┐ + │Anthropic│ │ OpenAI │ │ LLM APIs │ + │ OAuth │ │ OAuth │ │ │ + │ Endpoints│ │ Endpoints │ │ Message │ + │ │ │ │ │ Endpoints│ + └─────────┘ └──────────────┘ └────────────┘ +``` + +### Component Interactions + +1. **Settings Panel (UI):** User selects OAuth provider and initiates login +2. **OAuth Handler:** Generates PKCE parameters and redirects to provider +3. **Provider:** User authenticates and returns authorization code +4. **OAuth Handler:** Exchanges code for tokens using PKCE verifier +5. **Storage:** Credentials persisted in localStorage (encrypted by browser) +6. **Runtime:** Before each API call, checks token expiry and refreshes if needed +7. **LLM APIs:** Receive bearer token in Authorization header + +--- + +## Detailed Authentication Flow + +### Phase 1: User Initiates OAuth (Initial Login) + +**Trigger:** User clicks "Login with Provider" in settings panel + +**Steps:** +1. User selects provider (Anthropic or OpenAI) from settings dropdown +2. Svelte component calls `initializeOAuth(provider)` +3. OAuth handler generates PKCE parameters: + - `code_verifier`: 43-128 character random string (base64url encoded) + - `code_challenge`: SHA-256 hash of verifier (base64url encoded) +4. OAuth handler stores `code_verifier` and random `state` in localStorage +5. OAuth handler builds authorization URL with: + - `client_id`: Embedded provider client ID (base64-encoded in source) + - `redirect_uri`: Provider's callback URL + - `code_challenge` and `code_challenge_method=S256` + - `state`: CSRF protection token + - `scope`: Provider-specific OAuth scopes +6. Svelte redirects user to OAuth provider's authorization endpoint + +### Phase 2: User Authenticates with Provider + +**User Actions:** +1. User logs into their provider account (if not already authenticated) +2. Grants permission to OpenExcel application +3. Provider redirects back with authorization code + state + +**Security Checks:** +- `state` parameter is validated (CSRF protection) +- Authorization code is single-use +- Code is only valid for ~10 minutes + +### Phase 3: Exchange Authorization Code for Tokens + +**Trigger:** Authorization code received at redirect URI + +**Steps:** +1. Svelte component retrieves stored `code_verifier` and `state` from localStorage +2. Svelte calls `exchangeOAuthCode(code, verifier)` +3. OAuth handler POST to provider's token endpoint: + ```json + { + "grant_type": "authorization_code", + "code": "...", + "code_verifier": "...", + "client_id": "...", + "redirect_uri": "..." + } + ``` +4. Provider validates code_verifier against stored challenge (PKCE) +5. Provider returns tokens: + ```json + { + "access_token": "...", + "refresh_token": "...", + "expires_in": 3600, + "token_type": "Bearer" + } + ``` +6. OAuth handler stores credentials in localStorage: + ```json + { + "refresh": "refresh_token_value", + "access": "access_token_value", + "expires": 1713619200000 // timestamp in milliseconds + } + ``` +7. Svelte updates UI to "Connected" state + +### Phase 4: Token Refresh (Proactive Refresh) + +**Trigger:** Before each LLM API call + +**Steps:** +1. Runtime calls `getActiveApiKey(config)` to retrieve current access token +2. Runtime checks: `Date.now() < credentials.expires`? +3. **If expired or missing:** + - Runtime calls `refreshOAuthToken(refresh_token, provider)` + - OAuth handler POST to token endpoint: + ```json + { + "grant_type": "refresh_token", + "refresh_token": "...", + "client_id": "..." + } + ``` + - Provider returns new access token and updated expiry + - New credentials saved to localStorage + - Runtime uses new access token for LLM call +4. **If valid:** + - Runtime uses existing access token + - No token endpoint request needed + +### Phase 5: LLM API Request with Bearer Token + +**Headers Sent:** +``` +Authorization: Bearer {access_token} +Content-Type: application/json +``` + +**Response:** Streamed chat/completion response from LLM provider + +--- + +## Data Structures & Storage + +### OAuthCredentials Object + +```typescript +interface OAuthCredentials { + refresh: string; // Long-lived refresh token + access: string; // Short-lived access token (typically expires in 1 hour) + expires: number; // Expiration timestamp in milliseconds (Date.now() + expires_in*1000) +} +``` + +**Storage Key:** `{localStoragePrefix}-oauth-credentials` + +**Structure in localStorage:** +```json +{ + "openexcel-oauth-credentials": { + "anthropic": { + "refresh": "long-lived-refresh-token", + "access": "short-lived-access-token", + "expires": 1713619200000 + }, + "openai-codex": { + "refresh": "...", + "access": "...", + "expires": 1713619200000 + } + } +} +``` + +### ProviderConfig Object + +```typescript +interface ProviderConfig { + provider: string; // "anthropic" or "openai-codex" + apiKey: string; // Holds access token if authMethod="oauth" + model: string; // LLM model ID (e.g., "claude-3-5-sonnet-20241022") + authMethod: "apikey" | "oauth"; // Authentication method + useProxy: boolean; // Whether to use proxy for token requests + proxyUrl: string; // CORS proxy URL (if using proxy) + thinking: "none" | "low" | "medium" | "high"; + followMode: boolean; + expandToolCalls: boolean; + apiType?: string; // Optional API type override + customBaseUrl?: string; // Optional custom base URL +} +``` + +**Storage Key:** `{localStoragePrefix}-provider-config` + +### OAuthFlowState + +```typescript +type OAuthFlowState = + | { step: "idle" } // No auth attempted + | { step: "awaiting-code"; verifier: string; oauthState?: string } // Waiting for auth code + | { step: "exchanging" } // Exchanging code for tokens + | { step: "connected" } // Successfully authenticated + | { step: "error"; message: string }; // Auth failed +``` + +--- + +## Code Implementation Reference + +### 1. PKCE Generation + +**File:** `packages/sdk/src/oauth/index.ts` + +```typescript +export async function generatePKCE(): Promise<{ + verifier: string; + challenge: string; +}> { + // Generate cryptographically secure random bytes + const verifierBytes = new Uint8Array(32); + crypto.getRandomValues(verifierBytes); + const verifier = base64urlEncode(verifierBytes); + + // Create SHA-256 hash of verifier + const data = new TextEncoder().encode(verifier); + const hashBuffer = await crypto.subtle.digest("SHA-256", data); + const challenge = base64urlEncode(new Uint8Array(hashBuffer)); + + return { verifier, challenge }; +} + +function base64urlEncode(bytes: Uint8Array): string { + let binary = ""; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + // Replace URL-unsafe characters + return btoa(binary) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=/g, ""); +} +``` + +### 2. Authorization URL Construction + +**File:** `packages/sdk/src/oauth/index.ts` + +```typescript +export function buildAuthorizationUrl( + provider: string, + challenge: string, + verifier: string, +): { url: string; oauthState?: string } { + if (provider === "openai-codex") { + const oauthState = createRandomState(); + const params = new URLSearchParams({ + response_type: "code", + client_id: OPENAI_CODEX_CLIENT_ID, + redirect_uri: OPENAI_CODEX_REDIRECT_URI, + scope: OPENAI_CODEX_SCOPE, + code_challenge: challenge, + code_challenge_method: "S256", + state: oauthState, + id_token_add_organizations: "true", + codex_cli_simplified_flow: "true", + originator: "pi", + }); + return { url: `${OPENAI_CODEX_AUTHORIZE_URL}?${params}`, oauthState }; + } + + // Anthropic flow + const params = new URLSearchParams({ + code: "true", + client_id: ANTHROPIC_CLIENT_ID, + response_type: "code", + redirect_uri: ANTHROPIC_REDIRECT_URI, + scope: ANTHROPIC_SCOPES, + code_challenge: challenge, + code_challenge_method: "S256", + state: verifier, + }); + return { url: `${ANTHROPIC_AUTHORIZE_URL}?${params}` }; +} +``` + +### 3. Token Refresh Implementation + +**File:** `packages/sdk/src/oauth/index.ts` + +```typescript +async function refreshAnthropicOAuth( + refreshToken: string, + proxyUrl: string, + useProxy: boolean, +): Promise { + const url = buildProxiedUrl(ANTHROPIC_TOKEN_URL, useProxy, proxyUrl); + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + grant_type: "refresh_token", + client_id: ANTHROPIC_CLIENT_ID, + refresh_token: refreshToken, + }), + }); + + if (!response.ok) { + throw new Error(`Anthropic token refresh failed: ${response.status}`); + } + + const data = (await response.json()) as { + access_token: string; + refresh_token: string; + expires_in: number; + }; + + return { + refresh: data.refresh_token, + access: data.access_token, + expires: Date.now() + data.expires_in * 1000, + }; +} +``` + +### 4. Runtime Token Check Before API Call + +**File:** `packages/sdk/src/runtime.ts` + +```typescript +private async getActiveApiKey(config: ProviderConfig): Promise { + // Non-OAuth auth method: return API key as-is + if (config.authMethod !== "oauth") { + return config.apiKey; + } + + // Load OAuth credentials from storage + const creds = loadOAuthCredentials(this.ns, config.provider); + if (!creds) return config.apiKey; // Fallback to API key if no OAuth creds + + // Check if token is still valid + if (Date.now() < creds.expires) { + return creds.access; // Token is valid, use it + } + + // Token expired: refresh it + const refreshed = await refreshOAuthToken( + config.provider, + creds.refresh, + config.proxyUrl, + config.useProxy, + ); + + // Save updated credentials + saveOAuthCredentials(this.ns, config.provider, refreshed); + + // Return new access token + return refreshed.access; +} +``` + +### 5. Provider Configuration Loading + +**File:** `packages/sdk/src/provider-config.ts` + +```typescript +export function loadSavedConfig(ns: StorageNamespace): ProviderConfig | null { + try { + const saved = localStorage.getItem(storageKey(ns)); + if (saved) { + const config = JSON.parse(saved); + + // Set defaults for optional fields + if (config.proxyUrl === undefined) config.proxyUrl = ""; + if (config.followMode === undefined) config.followMode = true; + if (config.authMethod === undefined) config.authMethod = "apikey"; + + // For OAuth auth method: populate apiKey with access token + if (config.authMethod === "oauth") { + const creds = loadOAuthCredentials(ns, config.provider); + if (creds) config.apiKey = creds.access; + } + + return config; + } + } catch {} + return null; +} +``` + +--- + +## Provider-Specific Configurations + +### Anthropic Claude OAuth + +**Authorization Endpoint:** `https://claude.ai/oauth/authorize` +**Token Endpoint:** `https://console.anthropic.com/v1/oauth/token` +**Redirect URI:** `https://console.anthropic.com/oauth/code/callback` + +**Required Scopes:** +``` +org:create_api_key user:profile user:inference +``` + +**PKCE Support:** ✅ Yes (code_challenge_method=S256) + +**Token Lifetime:** +- Access Token: ~1 hour +- Refresh Token: Long-lived (does not expire unless revoked) + +### OpenAI ChatGPT OAuth + +**Authorization Endpoint:** `https://auth.openai.com/oauth/authorize` +**Token Endpoint:** `https://auth.openai.com/oauth/token` +**Redirect URI:** `http://localhost:1455/auth/callback` (local dev or custom handler) + +**Required Scopes:** +``` +openid profile email offline_access +``` + +**PKCE Support:** ✅ Yes (code_challenge_method=S256) + +**Token Lifetime:** +- Access Token: ~1 hour +- Refresh Token: Long-lived + +**Special Parameters:** +- `id_token_add_organizations=true`: Include organization list +- `codex_cli_simplified_flow=true`: Streamlined OAuth flow +- `originator=pi`: Identifies source as PI (Prompt Inspector) + +--- + +## Excel Add-in Integration + +### Storage Namespace Configuration + +**File:** `packages/excel/src/lib/adapter.ts` + +```typescript +const STORAGE_NAMESPACE = { + dbName: "OpenExcelDB_v3", + dbVersion: 30, + localStoragePrefix: "openexcel", + documentSettingsPrefix: "openexcel", + documentIdSettingsKey: "openexcel-workbook-id", +}; +``` + +### Session Scoping + +- Each workbook has a unique ID stored in Office.js document settings +- Chat sessions, VFS files, and OAuth credentials are scoped to workbook ID in IndexedDB +- Multiple workbooks can have different OAuth sessions +- Logout clears credentials for current workbook only + +### Taskpane Authentication UI + +**File:** `packages/core/src/chat/settings-panel.svelte` + +**UI States:** +1. **idle** — No authentication attempted; show provider selection + login button +2. **awaiting-code** — Waiting for user to paste/receive authorization code +3. **exchanging** — Requesting tokens from provider (loading state) +4. **connected** — Successfully authenticated; show provider name + logout button +5. **error** — Authentication failed; show error message + retry option + +**UI Features:** +- Dropdown to select auth method: "API Key" or "OAuth" +- Conditional rendering based on selected provider +- Login button triggers OAuth flow +- Manual code paste field (fallback for redirect issues) +- Logout button to clear credentials + +--- + +## Environment Variables & Configuration + +### Required Environment Variables + +Add to `.env` file (Vite automatically loads `VITE_*` prefixed variables): + +```bash +# Azure AD for future NAA/SSO authentication (not yet implemented) +VITE_AZURE_AD_CLIENT_ID=your-tenant-specific-client-id +VITE_AZURE_AD_TENANT_ID=your-tenant-id + +# Datadog monitoring (optional but recommended for production) +VITE_DD_CLIENT_TOKEN=your-datadog-client-token +VITE_DD_APP_ID=your-datadog-app-id +VITE_DD_SITE=datadoghq.eu +VITE_DD_SERVICE=excel-mate +VITE_DD_ENV=production +VITE_DD_VERSION=0.1.1 +``` + +### Embedded Configuration (in SDK) + +The following OAuth client IDs and endpoints are embedded in the source code (base64-encoded for obfuscation): + +| Provider | Client ID | Token URL | Redirect URI | +|----------|-----------|-----------|--------------| +| Anthropic | Embedded (base64) | `https://console.anthropic.com/v1/oauth/token` | `https://console.anthropic.com/oauth/code/callback` | +| OpenAI Codex | Embedded (base64) | `https://auth.openai.com/oauth/token` | `http://localhost:1455/auth/callback` | + +**Note:** These credentials are public client IDs used in browser contexts; they are intentionally not sensitive secrets. + +--- + +## Security Considerations + +### 1. PKCE Protection + +**What it does:** Prevents authorization code interception attacks in browser-based OAuth flows + +**How it works:** +- `code_verifier`: Random 43-128 byte string generated locally +- `code_challenge`: SHA-256 hash of verifier +- Only the challenge is sent to authorization endpoint +- Verifier is sent directly to token endpoint (not through browser redirect) +- Provider validates that `SHA-256(verifier) == challenge` + +**Why it matters:** If malicious code intercepts the authorization code, it cannot obtain tokens without the verifier. + +### 2. State Parameter + +**What it does:** CSRF (Cross-Site Request Forgery) protection + +**How it works:** +- Random `state` token generated before redirect +- Stored in localStorage +- Provider includes state in redirect back +- Application validates returned state matches stored state + +### 3. Token Storage + +**Current:** Browser localStorage (not ideal, but standard for SPAs) + +**Security:** +- Tokens stored in localStorage are accessible to any JavaScript on the page +- Protected by browser Same-Origin Policy (SOP) +- Encrypted by browser (TLS in transit) +- **Recommendation:** Use sessionStorage for short-lived access tokens only; store refresh tokens server-side + +### 4. Refresh Token Rotation + +**Current:** Refresh tokens stored indefinitely in localStorage + +**Security Considerations:** +- Long-lived tokens are valuable to attackers +- Providers may implement refresh token rotation (new refresh token per refresh) +- Implement token cleanup/revocation on logout + +### 5. Bearer Token Usage + +**Pattern:** +``` +Authorization: Bearer {access_token} +``` + +**Security:** +- Tokens transmitted in Authorization header (not URL parameter) +- TLS/HTTPS required (adding `integrity` and `confidentiality`) +- Access tokens are short-lived (~1 hour) + +### 6. Optional: CORS Proxy for Token Requests + +**File:** `packages/sdk/src/oauth/index.ts` + +**Use Case:** Some environments block direct cross-origin requests to token endpoints + +**Implementation:** +```typescript +function buildProxiedUrl( + baseUrl: string, + useProxy: boolean, + proxyUrl: string, +): string { + return useProxy && proxyUrl + ? `${proxyUrl}/?url=${encodeURIComponent(baseUrl)}` + : baseUrl; +} +``` + +**Risk:** Routing tokens through a proxy introduces a man-in-the-middle risk; only use trusted proxies or server-owned proxies. + +--- + +## Known Limitations & Future Enhancements + +### Current Limitations + +1. **No Backend Token Relay** + - All token handling occurs in the browser + - No server-side token storage or validation + - Tokens visible in browser developer tools + +2. **No Refresh Token Rotation** + - Refresh tokens stored indefinitely + - No automatic refresh token cleanup + +3. **No Multi-Account Support** + - Only one account per provider at a time + - Switching accounts requires logout + re-login + +4. **No Token Revocation Endpoint** + - Logout clears local storage but does not revoke tokens at provider + - Tokens remain valid until natural expiry + +### Planned Enhancements + +1. **Azure AD / NAA Integration** (in progress) + - MSAL.js for Microsoft Entra ID authentication + - Single Sign-On (SSO) for enterprise customers + - **Status:** Dependencies installed; implementation to follow + +2. **Secure Token Storage** + - Server-side token relay for sensitive applications + - HttpOnly cookies for tokens (if backend added) + - Token encryption at rest + +3. **Token Refresh Rotation** + - Automatic rotation of refresh tokens + - Cleanup of expired tokens + +4. **Logout with Token Revocation** + - Call provider API to explicitly revoke tokens + - Remove from storage + +--- + +## Testing & Validation Checklist + +### Manual Testing + +- [ ] Click "Login with Provider" button +- [ ] Redirect to OAuth provider succeeds +- [ ] User can authenticate with provider credentials +- [ ] Redirect back to add-in returns authorization code +- [ ] Token exchange succeeds; "Connected" state displayed +- [ ] Provider name and model appear in settings +- [ ] Send chat message; receives LLM response using OAuth token +- [ ] Wait until token expiry; send another message; automatic refresh occurs +- [ ] Click logout; credentials removed from localStorage +- [ ] Settings panel shows "Not connected" after logout + +### Automated Tests + +See: `packages/sdk/tests/` for runtime and provider-config tests + +--- + +## Troubleshooting + +### Issue: "OAuth flow failed" / "Unable to exchange code" + +**Possible Causes:** +1. Network firewall blocking token endpoint +2. CORS proxy not functioning +3. Provider API temporarily down +4. Wrong redirect URI configured + +**Resolution:** +- Check browser console for fetch error details +- Verify redirect URI matches provider's registered URI +- Try disabling proxy if enabled +- Contact provider support if endpoint is down + +### Issue: Token refresh fails; "Refresh token invalid" + +**Possible Causes:** +1. Refresh token expired (use case-specific; some providers invalidate tokens after 90 days) +2. User revoked token at provider +3. Provider requires re-authentication + +**Resolution:** +- User must re-authenticate (logout + login) +- Check provider's token expiration policy + +### Issue: Multiple browsers/tabs; tokens out of sync + +**Current Limitation:** localStorage is per-origin, not per-tab + +**Workaround:** Close and reopen tabs; refresh with F5 + +--- + +## Additional Resources + +- **OAuth 2.0 Spec:** https://tools.ietf.org/html/rfc6749 +- **PKCE Extension:** https://tools.ietf.org/html/rfc7636 +- **OpenID Connect:** https://openid.net/connect/ +- **Anthropic OAuth Documentation:** https://docs.anthropic.com/claude/reference/verify-account +- **OpenAI OAuth Documentation:** https://platform.openai.com/docs/guides/oauth + +--- + +## Appendix: Complete Provider Endpoints + +### Anthropic + +``` +Authorization: https://claude.ai/oauth/authorize +Token: https://console.anthropic.com/v1/oauth/token +Redirect: https://console.anthropic.com/oauth/code/callback +Scopes: org:create_api_key user:profile user:inference +``` + +### OpenAI Codex + +``` +Authorization: https://auth.openai.com/oauth/authorize +Token: https://auth.openai.com/oauth/token +Redirect: http://localhost:1455/auth/callback +Scopes: openid profile email offline_access +``` + diff --git a/docs/ENTRA-ID-CONFIG-CHECKLIST.md b/docs/ENTRA-ID-CONFIG-CHECKLIST.md new file mode 100644 index 0000000..4ac129b --- /dev/null +++ b/docs/ENTRA-ID-CONFIG-CHECKLIST.md @@ -0,0 +1,657 @@ +# Azure AD / Entra ID Configuration Checklist & Implementation Guide + +**Document Purpose:** This guide provides step-by-step instructions for configuring Azure Active Directory (Entra ID) OAuth for a Static Web App (SWA) to replicate the OpenExcel authentication pattern. + +**Pre-Requisites:** +- Azure subscription with Entra ID tenant +- Static Web App created in Azure +- Permissions to manage App Registrations in Entra ID + +--- + +## Part 1: App Registration Setup in Entra ID + +### Step 1: Create New App Registration + +1. Navigate to **Azure Portal** → **Entra ID** → **App registrations** +2. Click **New registration** +3. Fill in details: + - **Name:** `OpenExcel SWA` (or your application name) + - **Supported account types:** Choose based on your needs: + - ✅ **Accounts in this organizational directory only** — Single-tenant (recommended for enterprise) + - ⭕ **Accounts in any organizational directory** — Multi-tenant (for broader access) + - **Redirect URI:** + - Platform: **Single-page application (SPA)** + - URI: `https://{your-swa-domain}.azurestaticapps.net/auth/callback` + - For local dev: `http://localhost:3000/auth/callback` +4. Click **Register** + +### Step 2: Note Client Credentials + +On the **Overview** page, copy and save: +- **Application (client) ID** — GUID used in OAuth requests +- **Directory (tenant) ID** — Your tenant GUID + +These will be used in environment variables: +``` +VITE_AZURE_AD_CLIENT_ID={Application (client) ID} +VITE_AZURE_AD_TENANT_ID={Directory (tenant) ID} +``` + +### Step 3: Configure Redirect URIs + +1. Navigate to **Authentication** in the left sidebar +2. Under **Redirect URIs**, add: + - `https://{your-swa-domain}.azurestaticapps.net/auth/callback` + - `https://{your-swa-domain}.azurestaticapps.net` (for non-callback redirects) + - For development: `http://localhost:3000` and `http://localhost:3000/auth/callback` +3. Ensure "Treat `https://{your-custom-domain}/auth/callback` as a public client redirect URI" is **CHECKED** +4. Click **Save** + +--- + +## Part 2: API Permissions & Scopes + +### Step 4: Add API Permissions + +1. Navigate to **API permissions** in the left sidebar +2. Click **Add a permission** +3. Select **Microsoft Graph** +4. Choose **Delegated permissions** (user-context access) +5. Search for and select: + - ✅ `User.Read` — Read basic user profile (required) + - ✅ `profile` — Include profile in token claims + - ✅ `email` — Include email in token claims + - ⭕ `offline_access` — Enable refresh token (recommended) + - ⭕ `Directory.Read.All` — Read directory/groups (if using group-based authorization) + +6. Click **Add permissions** + +### Step 5: Grant Admin Consent (Optional but Recommended) + +1. After adding permissions, click **Grant admin consent for {tenant}** +2. Click **Yes** to confirm +3. Status should show green checkmarks under "Granted" + +--- + +## Part 3: Token Configuration + +### Step 6: Configure Token Claims + +1. Navigate to **Token configuration** in the left sidebar +2. Click **Add optional claim** +3. Select token type: **ID** (for identity) and/or **Access** (for API calls) +4. Select claims to include: + - ✅ `email` — User's email address + - ✅ `given_name` — First name + - ✅ `family_name` — Last name + - ✅ `upn` — User Principal Name + - ⭕ `groups` — Security groups (for role-based authorization) + - ⭕ `roles` — App Roles (if using RBAC) + +5. Click **Add** +6. Repeat for "Access" token if needed + +### Step 7: Configure Issued Token Lifetime (Optional) + +1. Stay in **Token configuration** → **Protocol settings** +2. Set token lifetimes (defaults are usually fine): + - **ID token lifetime:** 10 min (default) + - **Access token lifetime:** 1 hour (default) + - **Refresh token lifetime:** 14 days (default) +3. Click **Save** + +--- + +## Part 4: Client Secret (For Backend Integration) + +### Step 8: Create Client Secret (If Backend OAuth Relay Needed) + +**Only required if implementing a backend token relay service** (not needed for pure client-side BYOK model) + +1. Navigate to **Certificates & secrets** in the left sidebar +2. Click **New client secret** +3. Fill in: + - **Description:** `SWA OAuth Backend Secret` + - **Expires:** Select timeframe (1 year recommended) +4. Click **Add** +5. **IMMEDIATELY** copy and save the secret value + - ⚠️ This value is only shown once + - Store securely (e.g., Azure Key Vault, GitHub Secrets) + +**Never commit secrets to version control.** + +--- + +## Part 5: Application Roles (RBAC Setup) + +### Step 9: Define App Roles (Optional, for Authorization) + +1. Navigate to **App roles** in the left sidebar +2. Click **Create app role** +3. Define roles for your application: + +**Role 1: User** +- **Display name:** `OpenExcel User` +- **Allowed member types:** Users/Groups +- **Value:** `Excel.User` +- **Description:** `Basic user access to OpenExcel` +- Click **Create** + +**Role 2: Admin** (optional) +- **Display name:** `OpenExcel Admin` +- **Allowed member types:** Users/Groups +- **Value:** `Excel.Admin` +- **Description:** `Administrative access to OpenExcel` +- Click **Create** + +### Step 10: Assign Users to App Roles + +1. Navigate to **Manage** → **Users and groups** in the left sidebar +2. Click **Add user/group** +3. Select users or groups +4. Assign roles +5. Click **Assign** + +--- + +## Part 6: Protected API Configuration (If Using Azure APIM) + +### Step 11: Create API in Azure API Management (Optional) + +**Only if adding an Azure API Management gateway for token validation** + +1. Navigate to **Azure API Management** service +2. Go to **APIs** → **Add API** +3. Define API: + - **Name:** `OpenExcel API` + - **Display name:** `OpenExcel API` + - **Base URL:** `https://{your-backend-function-app}.azurewebsites.net` + +### Step 12: Add TOKEN VALIDATION Policy to APIM (Optional) + +**In API Management → Policies → Design → Add policy** + +```xml + + + + + + {application-client-id} + + + https://sts.windows.net/{tenant-id}/ + + + + {application-client-id} + + + + + + + + + + + + + + +``` + +--- + +## Part 7: Environment Variables Setup + +### Step 13: Create `.env` File with OAuth Configuration + +**File:** `.env` (root of SWA project) + +```bash +# ============================================ +# Azure AD / Entra ID OAuth Configuration +# ============================================ + +# Azure AD Client ID (Application ID from App Registration) +VITE_AZURE_AD_CLIENT_ID=12345678-1234-1234-1234-123456789012 + +# Azure AD Tenant ID (Directory ID from App Registration) +VITE_AZURE_AD_TENANT_ID=87654321-4321-4321-4321-210987654321 + +# OAuth Authority (endpoint for obtaining tokens) +# Format: https://login.microsoftonline.com/{tenant-id}/v2.0 +VITE_AZURE_AD_AUTHORITY=https://login.microsoftonline.com/87654321-4321-4321-4321-210987654321/v2.0 + +# OAuth Redirect URI (must match App Registration configuration) +VITE_AZURE_AD_REDIRECT_URI=https://your-swa-domain.azurestaticapps.net/auth/callback + +# OAuth Scopes (space-separated) +# Scopes available: User.Read, profile, email, offline_access, Directory.Read.All +VITE_AZURE_AD_SCOPES=User.Read profile email offline_access + +# ============================================ +# Application Configuration +# ============================================ + +# Front-End Base URL +VITE_APP_URL=https://your-swa-domain.azurestaticapps.net + +# Back-End API Base URL (if applicable) +VITE_API_URL=https://your-backend-api.azurewebsites.net + +# ============================================ +# Feature Flags (Optional) +# ============================================ + +# Enable/disable OAuth SSO +VITE_ENABLE_OAUTH_SSO=true + +# Enable/disable BYOK (Bring Your Own Key) mode +VITE_ENABLE_BYOK=true + +# ============================================ +# Monitoring (Optional) +# ============================================ + +# Datadog monitoring configuration +VITE_DD_CLIENT_TOKEN=pub123456789abcdef +VITE_DD_APP_ID=12345678-1234-1234-1234-123456789012 +VITE_DD_SITE=datadoghq.eu +VITE_DD_ENV=production +``` + +### Step 14: Development (`localhost`) vs. Production Configuration + +**Development (`localhost:3000`):** +```bash +VITE_AZURE_AD_CLIENT_ID=dev-client-id-from-app-registration +VITE_AZURE_AD_REDIRECT_URI=http://localhost:3000/auth/callback +VITE_APP_URL=http://localhost:3000 +``` + +**Production (SWA Domain):** +```bash +VITE_AZURE_AD_CLIENT_ID=prod-client-id-from-app-registration +VITE_AZURE_AD_REDIRECT_URI=https://your-swa-domain.azurestaticapps.net/auth/callback +VITE_APP_URL=https://your-swa-domain.azurestaticapps.net +``` + +--- + +## Part 8: MSAL.js Integration in SWA + +### Step 15: Install MSAL.js Dependencies + +```bash +npm install @azure/msal-browser @azure/msal-react +# or with pnpm +pnpm add @azure/msal-browser @azure/msal-react +``` + +### Step 16: Initialize MSAL in Application + +**File:** `src/main.tsx` or `src/main.ts` + +```typescript +import { PublicClientApplication } from "@azure/msal-browser"; + +const msalConfig = { + auth: { + clientId: import.meta.env.VITE_AZURE_AD_CLIENT_ID, + authority: import.meta.env.VITE_AZURE_AD_AUTHORITY, + redirectUri: import.meta.env.VITE_AZURE_AD_REDIRECT_URI, + }, + cache: { + cacheLocation: "localStorage", // or "sessionStorage" + storeAuthStateInCookie: false, + }, + system: { + allowRedirectInIframe: false, + loggerOptions: { + loggerCallback: (logLevel, message, containsPii) => { + console.log("[MSAL]", message); + }, + piiLoggingEnabled: false, + }, + }, +}; + +const msalInstance = new PublicClientApplication(msalConfig); + +// Handle redirect after OAuth callback +msalInstance + .handleRedirectPromise() + .then((tokenResponse) => { + if (tokenResponse) { + console.log("Login successful", tokenResponse); + } + }) + .catch((error) => { + console.error("MSAL error:", error); + }); +``` + +### Step 17: Wrap Application with MsalProvider + +**File:** `src/App.tsx` or root Svelte component + +```typescript +import { MsalProvider } from "@azure/msal-react"; +import { ChatInterface } from "@office-agents/core"; + +function App() { + return ( + + + + ); +} +``` + +### Step 18: Implement Login Handler + +**File:** `src/components/auth.ts` or similar + +```typescript +import { useMsal } from "@azure/msal-react"; + +export function useAuthHandler() { + const { instance, accounts } = useMsal(); + + const login = async () => { + try { + const response = await instance.loginPopup({ + scopes: import.meta.env.VITE_AZURE_AD_SCOPES?.split(" "), + prompt: "select_account", + }); + console.log("Login successful", response); + return response; + } catch (error) { + console.error("Login failed", error); + throw error; + } + }; + + const logout = async () => { + try { + await instance.logoutPopup(); + console.log("Logout successful"); + } catch (error) { + console.error("Logout failed", error); + } + }; + + const getAccessToken = async () => { + if (accounts.length === 0) return null; + + try { + const response = await instance.acquireTokenSilent({ + scopes: import.meta.env.VITE_AZURE_AD_SCOPES?.split(" "), + account: accounts[0], + }); + return response.accessToken; + } catch (error) { + console.error("Token acquisition failed", error); + // Fallback to interactive token acquisition + const response = await instance.acquireTokenPopup({ + scopes: import.meta.env.VITE_AZURE_AD_SCOPES?.split(" "), + }); + return response.accessToken; + } + }; + + return { login, logout, getAccessToken, isAuthenticated: accounts.length > 0 }; +} +``` + +--- + +## Part 9: Backend API Token Validation (Optional) + +### Step 19: Azure Function with Token Validation + +**File:** `api/validate-token/index.ts` (Azure Function) + +```typescript +import { Context, HttpRequest } from "@azure/functions"; +import { jwtDecode } from "jwt-decode"; +import fetch from "node-fetch"; + +export async function validateOAuthToken( + context: Context, + req: HttpRequest, +): Promise { + const authHeader = req.headers.get("Authorization"); + + if (!authHeader || !authHeader.startsWith("Bearer ")) { + context.res = { + status: 401, + body: { error: "Missing or invalid Authorization header" }, + }; + return; + } + + const token = authHeader.substring(7); + + try { + // Fetch OIDC metadata + const tenantId = process.env.AZURE_AD_TENANT_ID; + const response = await fetch( + `https://login.microsoftonline.com/${tenantId}/.well-known/openid-configuration`, + ); + const oidcConfig = (await response.json()) as { + jwks_uri: string; + }; + + // Fetch public keys + const keysResponse = await fetch(oidcConfig.jwks_uri); + const keys = await keysResponse.json(); + + // Decode token header to get key ID (kid) + const decoded = jwtDecode(token, { header: true }); + const kid = (decoded as Record).kid; + + // Find matching public key + const key = keys.keys.find( + (k: Record) => k.kid === kid, + ); + if (!key) { + throw new Error("Public key not found"); + } + + // Validate token signature and claims + // (Use a JWT library like jsonwebtoken to handle verification properly) + context.res = { + status: 200, + body: { + valid: true, + claims: decoded, + }, + }; + } catch (error) { + context.res = { + status: 401, + body: { error: "Token validation failed", message: (error as Error).message }, + }; + } +} +``` + +--- + +## Part 10: Static Web App Configuration + +### Step 20: Configure SWA `staticwebapp.config.json` + +**File:** `staticwebapp.config.json` (root of SWA project) + +```json +{ + "auth": { + "identityProviders": { + "azureActiveDirectory": { + "registration": { + "openIdIssuer": "https://login.microsoftonline.com/{tenant-id}/v2.0", + "clientIdSettingName": "AZURE_CLIENT_ID", + "clientSecretSettingName": "AZURE_CLIENT_SECRET" + }, + "login": { + "loginParameters": ["scope=User.Read profile email offline_access"] + } + } + } + }, + "routes": [ + { + "route": "/auth/*", + "allowedRoles": ["authenticated"] + }, + { + "route": "/api/*", + "allowedRoles": ["authenticated"] + }, + { + "route": "/*", + "serve": "index.html", + "statusCode": 200 + } + ] +} +``` + +--- + +## Part 11: Azure Static Web App Environment Variables + +### Step 21: Set Environment Variables in SWA + +1. Navigate to **Azure Portal** → **Static Web Apps** → Your SWA instance +2. Go to **Settings** → **Configuration** +3. Add environment variables: + +``` +VITE_AZURE_AD_CLIENT_ID = {copy from Step 2} +VITE_AZURE_AD_TENANT_ID = {copy from Step 2} +VITE_AZURE_AD_AUTHORITY = https://login.microsoftonline.com/{tenant-id}/v2.0 +VITE_AZURE_AD_REDIRECT_URI = https://{your-swa-domain}.azurestaticapps.net/auth/callback +VITE_AZURE_AD_SCOPES = User.Read profile email offline_access +VITE_APP_URL = https://{your-swa-domain}.azurestaticapps.net +``` + +--- + +## Configuration Validation Checklist + +Use this checklist to verify all OAuth configurations are in place: + +### App Registration + +- [ ] App registration created in Entra ID +- [ ] Client ID copied and saved +- [ ] Tenant ID copied and saved +- [ ] Redirect URIs configured (production + localhost dev) +- [ ] SPA platform selected for redirect URI type + +### API Permissions + +- [ ] `User.Read` permission added +- [ ] `profile` and `email` scopes added +- [ ] `offline_access` scope added (for refresh tokens) +- [ ] Admin consent granted (green checkmarks visible) + +### Token Configuration + +- [ ] Optional claims configured (email, groups, roles) +- [ ] Token lifetimes set appropriately +- [ ] ID token and Access token lifetimes configured + +### MSAL.js Setup + +- [ ] MSAL.js dependencies installed +- [ ] MSAL config object created with correct client ID and authority +- [ ] MsalProvider wraps application +- [ ] handleRedirectPromise() called on app startup +- [ ] Login handler implemented with correct scopes +- [ ] Token acquisition implemented (silent + interactive fallback) + +### Environment Variables + +- [ ] `.env` file created with all OAuth variables +- [ ] `VITE_AZURE_AD_CLIENT_ID` set +- [ ] `VITE_AZURE_AD_TENANT_ID` set +- [ ] `VITE_AZURE_AD_AUTHORITY` set +- [ ] `VITE_AZURE_AD_REDIRECT_URI` set +- [ ] `VITE_AZURE_AD_SCOPES` set +- [ ] SWA environment variables configured in Azure Portal + +### SWA Configuration + +- [ ] `staticwebapp.config.json` configured +- [ ] Routes restricted to authenticated users where appropriate +- [ ] SWA configured in Azure Portal + +### Testing + +- [ ] Login flow works locally (`localhost:3000`) +- [ ] OAuth redirect successful +- [ ] Tokens received and stored +- [ ] Login flow works in production SWA domain +- [ ] Logout clears tokens +- [ ] Token refresh works automatically +- [ ] API calls include Authorization header with bearer token + +--- + +## Troubleshooting + +### "AADSTS50058: Silent sign-in request failed" + +**Cause:** Browser blocked third-party cookies or user not logged into Microsoft account + +**Solution:** +- Ensure browser allows third-party cookies for the identity provider +- User must log into Microsoft account separately or in pop-up window +- Use `loginPopup()` instead of `loginRedirect()` for more reliable flow + +### "AADSTS650052: The app needs access to a service that your administrator has not yet authorized" + +**Cause:** Required API permissions not granted or admin consent not provided + +**Solution:** +- Go to App Registration → **API permissions** +- Add missing scopes +- Grant admin consent (green checkmark should appear) + +### "Redirect URI mismatch" + +**Cause:** Redirect URI in code doesn't match App Registration configuration + +**Solution:** +- Navigate to **App Registration** → **Authentication** +- Verify redirect URIs listed match those in MSAL config and environment variables +- Ensure exact match (case-sensitive, including trailing slash) + +### Token is undefined after login + +**Cause:** Token not being acquired or stored + +**Solution:** +- Check browser console for MSAL errors +- Verify scopes include `offline_access` for refresh tokens +- Ensure `acquireTokenSilent()` is called after login +- In incognito window, must use `acquireTokenPopup()` due to cookie restrictions + +--- + +## Additional Resources + +- [Microsoft Entra ID Documentation](https://learn.microsoft.com/en-us/entra/identity/) +- [MSAL.js Documentation](https://github.com/AzureAD/microsoft-authentication-library-for-js) +- [OAuth 2.0 Protocol](https://tools.ietf.org/html/rfc6749) +- [Azure Static Web Apps Auth](https://learn.microsoft.com/en-us/azure/static-web-apps/authentication-authorization) +- [Azure API Management JWT Validation](https://learn.microsoft.com/en-us/azure/api-management/api-management-access-restriction-policies) + diff --git a/docs/README-AUTH-DOCUMENTATION.md b/docs/README-AUTH-DOCUMENTATION.md new file mode 100644 index 0000000..6d83e1c --- /dev/null +++ b/docs/README-AUTH-DOCUMENTATION.md @@ -0,0 +1,485 @@ +# OpenExcel Authentication Flow - Complete Documentation Package + +**Date:** April 20, 2026 +**Status:** Complete & Ready for Implementation +**Version:** 1.0 + +--- + +## 📋 Documentation Package Overview + +This package contains a comprehensive blueprint of the OpenExcel authentication flow, designed to facilitate replication of the proven auth pattern in a new Static Web App (SWA). The documentation is organized into four primary deliverables plus this summary document. + +### Included Deliverables + +1. **AUTH-FLOW-DOCUMENTATION.md** (Core Technical Guide) + - End-to-end authentication flow explanation + - PKCE OAuth 2.0 protocol details + - Implementation code snippets + - Security considerations + - Troubleshooting guide + +2. **ENTRA-ID-CONFIG-CHECKLIST.md** (Step-by-Step Setup) + - Azure AD App Registration creation + - API permissions configuration + - Token claims setup + - MSAL.js integration guide + - SWA configuration + - Complete 21-step implementation checklist + +3. **ENVIRONMENT-VARIABLES-REFERENCE.md** (Configuration Guide) + - Detailed environment variable reference + - Dev/Staging/Production `.env` templates + - Token storage schema reference + - 10 production-ready code snippets + - Debugging tips and quick start commands + +4. **Architecture & Sequence Diagrams** (Visual Reference) + - High-level component architecture diagram + - Detailed OAuth 2.0 PKCE flow sequence diagram + - All major components and interactions illustrated + +--- + +## 🎯 Key Findings Summary + +### Current Implementation (OpenExcel) + +**Authentication Method:** OAuth 2.0 with PKCE +**Supported Providers:** Anthropic Claude, OpenAI ChatGPT +**User Model:** BYOK (Bring Your Own Key) +**Architecture:** Client-side only (browser-based) +**Token Storage:** Browser localStorage + IndexedDB +**Token Refresh:** Proactive (before each API call) + +### Critical Implementation Details Found + +1. **PKCE Protection** + - Code verifier: 32-byte random string (base64url encoded) + - Code challenge: SHA-256 hash of verifier (S256 method) + - Prevents authorization code interception + +2. **Token Lifecycle** + - Access token: ~1 hour lifetime + - Refresh token: Long-lived (indefinite) + - Automatic refresh triggered before expired token is used + - Runtime checks expiry: `Date.now() < credentials.expires` + +3. **Storage Architecture** + - localStorage key: `{prefix}-oauth-credentials` + - Structure: `{ provider: { refresh, access, expires } }` + - Scoped per workbook (for Excel add-in) + - Multiple providers can have simultaneous sessions + +4. **UI State Management** + - Five OAuth flow states tracked in Svelte components + - States: idle → awaiting-code → exchanging → connected → error + - User can manually paste auth code (redirect fallback) + +5. **Provider Configuration** + - Client IDs embedded (base64-encoded in source) + - Endpoints: Anthropic and OpenAI OAuth providers + - Scopes configured per provider + +--- + +## 🔐 Security Model + +### PKCE (Proof Key for Code Exchange) +- **Purpose:** Protect against authorization code interception +- **Implementation:** SHA-256 hash of random verifier +- **Protection:** Verifier never exposed to browser redirect + +### State Parameter +- **Purpose:** CSRF (Cross-Site Request Forgery) prevention +- **Implementation:** Random token generated and stored +- **Validation:** Verify returned state matches stored value + +### Bearer Token Usage +- **Format:** `Authorization: Bearer {access_token}` +- **Transport:** Authorization header (not URL parameter) +- **Requirement:** TLS/HTTPS enforced + +### Token Storage +- **Current:** Browser localStorage (SOP protected) +- **Concern:** Accessible to page JavaScript +- **Future Improvement:** Server-side token relay recommended + +--- + +## 📊 Flow Summary + +### Initial Authentication +``` +User clicks "Login" + → Generate PKCE (verifier + challenge) + → Redirect to OAuth provider + → User authenticates + → Provider returns authorization code + → Exchange code + PKCE verifier for tokens + → Store { access, refresh, expires } in localStorage + → UI shows "Connected" +``` + +### Token Usage & Refresh +``` +User sends chat message + → Runtime calls getActiveApiKey() + → Check: is token expired? + → If expired: refreshOAuthToken() with refresh_token + → Use access_token in Bearer header + → Send request to LLM API + → Stream response back to user +``` + +### Logout +``` +User clicks logout + → Remove OAuth credentials from localStorage + → Clear session data + → UI shows "Not Connected" +``` + +--- + +## 🛠️ Implementation Roadmap for New SWA + +### Phase 1: Azure AD Setup (2-3 hours) +- [ ] Create Entra ID App Registration +- [ ] Note Client ID and Tenant ID +- [ ] Configure redirect URIs (dev + production) +- [ ] Add API permissions (User.Read, etc.) +- [ ] Grant admin consent +- [ ] Create `.env` file with credentials + +**Reference Document:** `ENTRA-ID-CONFIG-CHECKLIST.md` — Follow steps 1-14 + +### Phase 2: MSAL.js Integration (4-6 hours) +- [ ] Install MSAL.js dependencies +- [ ] Create MSAL config object +- [ ] Initialize MSAL on app startup +- [ ] Implement handleRedirectPromise() +- [ ] Create login/logout handlers +- [ ] Implement token acquisition (silent + interactive) +- [ ] Add authentication state hook/store + +**Reference Document:** `ENVIRONMENT-VARIABLES-REFERENCE.md` — See snippets 1-8 + +### Phase 3: Backend Token Validation (3-4 hours, optional) +- [ ] Create Azure Function for token validation +- [ ] Implement JWT verification with JWKS +- [ ] Add JWT validation policy to APIM (if using gateway) +- [ ] Protect APIs with authentication middleware + +**Reference Document:** `AUTH-FLOW-DOCUMENTATION.md` — See "Backend Integration" section + +### Phase 4: Testing & Validation (2-3 hours) +- [ ] Test login flow locally +- [ ] Verify token acquisition +- [ ] Test automatic token refresh +- [ ] Test logout and credential cleanup +- [ ] Deploy to staging SWA +- [ ] Run production validation checklist + +**Reference Document:** `AUTH-FLOW-DOCUMENTATION.md` — See "Testing & Validation" section + +**Total Estimated Time:** 11-16 hours of development + +--- + +## 📁 File Structure for SWA Project + +**Recommended directory structure:** + +``` +src/ +├── auth/ +│ ├── msal-config.ts (MSAL setup) +│ ├── auth-handler.ts (Login/logout/token functions) +│ └── auth-hook.ts (React hook or Svelte store) +├── components/ +│ ├── AuthButton.tsx (Login/logout button) +│ └── ProtectedRoute.tsx (Require auth component) +├── api/ +│ └── api-client.ts (Bearer token requests) +├── App.tsx (Wrap with MsalProvider) +└── main.tsx (Initialize MSAL) + +api/ +└── validate-token/ + └── index.ts (Azure Function for token validation) + +.env (Local development) +.env.staging (Staging environment) +.env.production (Production environment) +staticwebapp.config.json (SWA routing & auth config) +``` + +--- + +## 🔍 Key Files in Original Codebase + +For reference, the current OpenExcel implementation uses: + +| File | Purpose | +|------|---------| +| `packages/sdk/src/oauth/index.ts` | PKCE generation, token exchange, refresh | +| `packages/sdk/src/provider-config.ts` | OAuth config storage and loading | +| `packages/sdk/src/runtime.ts` | Token validation before API calls | +| `packages/core/src/chat/settings-panel.svelte` | OAuth UI and flow management | +| `packages/excel/src/lib/adapter.ts` | Excel add-in integration | +| `packages/sdk/src/storage/` | IndexedDB and localStorage management | + +--- + +## 🚀 Quick Start for New SWA + +### 1. Create App Registration +```bash +# Navigate to: Azure Portal → Entra ID → App Registrations → New registration +# Follow: ENTRA-ID-CONFIG-CHECKLIST.md steps 1-5 +``` + +### 2. Clone/Create SWA Project +```bash +npx create-react-app my-swa-app +# or with Next.js +npx create-next-app@latest my-swa-app +# or with Svelte +npm create vite@latest my-swa-app -- --template svelte +``` + +### 3. Install Dependencies +```bash +npm install @azure/msal-browser @azure/msal-react jose +``` + +### 4. Add Configuration +```bash +# Create .env file with values from App Registration +cp .env.template .env +# Edit with your Client ID, Tenant ID, etc. +``` + +### 5. Integrate MSAL Code +```bash +# Use code snippets from ENVIRONMENT-VARIABLES-REFERENCE.md +# Snippets 1-8 provide complete MSAL setup +``` + +### 6. Deploy to SWA +```bash +# Push to GitHub +git push origin main + +# GitHub Actions will build and deploy to Azure Static Web Apps +``` + +--- + +## ⚠️ Important Notes & Limitations + +### Current Limitations +1. **No backend token storage** — Tokens visible in browser localStorage +2. **No refresh token rotation** — Tokens stored indefinitely +3. **No multi-account support** — One account per provider at a time +4. **No token revocation** — Logout doesn't revoke at provider + +### Security Recommendations +1. Use **HttpOnly cookies** with a backend token relay for production +2. Implement **refresh token rotation** if using long-lived tokens +3. Add **token cleanup** on logout (revoke at provider) +4. Consider **Content Security Policy (CSP)** to protect against XSS + +### Future Enhancements Tracked in Codebase +- Azure AD NAA/SSO integration (MSAL as fallback) +- Server-side token relay implementation +- Token storage encryption +- Multi-provider simultaneous sessions +- Built-in token revocation on logout + +--- + +## 📚 External References + +### OAuth & Security Standards +- [OAuth 2.0 Specification](https://tools.ietf.org/html/rfc6749) +- [PKCE Extension (RFC 7636)](https://tools.ietf.org/html/rfc7636) +- [OpenID Connect 1.0](https://openid.net/specs/openid-connect-core-1_0.html) + +### Azure & MSAL Documentation +- [Microsoft Entra ID Overview](https://learn.microsoft.com/en-us/entra/identity/) +- [MSAL.js GitHub Repository](https://github.com/AzureAD/microsoft-authentication-library-for-js) +- [Azure Static Web Apps Authentication](https://learn.microsoft.com/en-us/azure/static-web-apps/authentication-authorization) + +### Provider Documentation +- [Anthropic OAuth](https://docs.anthropic.com/claude/reference/verify-account) +- [OpenAI OAuth](https://platform.openai.com/docs/guides/oauth) + +--- + +## 📞 Troubleshooting Quick Reference + +### Common Issues & Solutions + +| Issue | Cause | Solution | +|-------|-------|----------| +| AADSTS50058: Silent sign-in failed | Browser blocked cookies | Ensure 3rd-party cookies allowed; use popup login | +| Redirect URI mismatch | Config doesn't match App Reg | Verify exact URI match (case-sensitive) in App Registration | +| Token is undefined | Token not acquired | Check scopes include `offline_access`; verify token acquisition called | +| "No user logged in" after refresh | Token not persisted | Check localStorage cache location set correctly in MSAL config | +| API 401 Unauthorized | Invalid/expired token | Verify Bearer token format; check token expiry and refresh logic | + +For detailed troubleshooting, see `AUTH-FLOW-DOCUMENTATION.md` → "Troubleshooting" section. + +--- + +## ✅ Pre-Deployment Checklist + +### Before Going to Production + +**Configuration:** +- [ ] All environment variables set in Azure Portal +- [ ] Client ID and Tenant ID verified +- [ ] Redirect URI points to production SWA domain +- [ ] staticwebapp.config.json configured properly + +**Code Quality:** +- [ ] MSAL logging disabled (piiLoggingEnabled=false) +- [ ] No console errors in production build +- [ ] CSP headers configured if using CSP +- [ ] No secrets committed to version control + +**Testing:** +- [ ] Login flow tested end-to-end +- [ ] Token refresh tested (wait for expiry) +- [ ] Logout tested +- [ ] Protected routes require authentication +- [ ] Error states handled gracefully + +**Security:** +- [ ] HTTPS enforced (TLS/SSL certificate) +- [ ] CORS configured correctly +- [ ] Backend token validation implemented (if applicable) +- [ ] CSP policy deployed +- [ ] Rate limiting configured (if applicable) + +**Monitoring:** +- [ ] Datadog RUM configured (if monitoring enabled) +- [ ] Error logging setup +- [ ] Authentication success/failure metrics tracked + +--- + +## 📝 Document Cross-References + +Each document includes references to the others: + +- **AUTH-FLOW-DOCUMENTATION.md** + - Detailed technical implementation + - Sequences and state machines + - Code patterns and examples + - References ENVIRONMENT-VARIABLES and ENTRA-ID-CONFIG docs + +- **ENTRA-ID-CONFIG-CHECKLIST.md** + - Step-by-step setup instructions + - Configuration screenshots/paths + - References AUTH-FLOW for protocol details + - References ENVIRONMENT-VARIABLES for variable setup + +- **ENVIRONMENT-VARIABLES-REFERENCE.md** + - Configuration reference for all variables + - Ready-to-use code snippets + - Environment-specific templates + - References AUTH-FLOW for implementation details + +- **Architecture & Sequence Diagrams** + - Visual representation of all components + - Used alongside AUTH-FLOW-DOCUMENTATION + - Supplement all three markdown documents + +--- + +## 🎓 Learning Path + +**For new team members onboarding to this auth pattern:** + +1. **Start here:** Read this summary document (5 min) +2. **Understand the flow:** Review architecture and sequence diagrams (10 min) +3. **Learn the protocol:** Read AUTH-FLOW-DOCUMENTATION.md sections 1-4 (20 min) +4. **See code examples:** Review ENVIRONMENT-VARIABLES-REFERENCE.md snippets 1-5 (15 min) +5. **Implement:** Follow ENTRA-ID-CONFIG-CHECKLIST.md step-by-step (4-6 hours) +6. **Reference:** Use all three markdown docs and diagrams during development + +**Total learning time:** ~45 min theory + 4-6 hours implementation + +--- + +## 🎯 Next Steps + +### Immediate Actions + +1. **Assign ownership:** Designate team member to lead SWA implementation +2. **Review documentation:** Have team read this package +3. **Create Entra ID App Reg:** Allocate 2-3 hours for setup +4. **Create dev SWA:** Deploy test instance to Azure +5. **Implement MSAL:** Follow the code snippets in Environment Variables doc + +### Proof of Concept Timeline + +- **Week 1:** Azure AD setup + MSAL integration (Phase 1-2) +- **Week 2:** Testing and validation (Phase 4) +- **Week 3:** Documentation review and handoff + +### Production Deployment Timeline + +- **After POC validation:** Deploy to staging SWA +- **After staging validation:** Deploy to production SWA +- **Ongoing:** Monitor auth flows with observability tools + +--- + +## 📄 Document Index + +| Document | Purpose | Audience | Time to Read | +|----------|---------|----------|--------------| +| **This Summary** | Overview & roadmap | All | 10 min | +| **AUTH-FLOW-DOCUMENTATION.md** | Technical deep-dive | Engineers | 30 min | +| **ENTRA-ID-CONFIG-CHECKLIST.md** | Implementation guide | DevOps/Engineers | 45 min | +| **ENVIRONMENT-VARIABLES-REFERENCE.md** | Configuration reference | Engineers | 20 min | +| **Architecture Diagram** | System visualization | All | 5 min | +| **Sequence Diagram** | OAuth flow visualization | All | 5 min | + +--- + +## 🏆 Success Criteria + +Your new SWA authentication implementation is successful when: + +✅ Users can log in with Azure AD credentials +✅ Access tokens acquired and stored securely +✅ Tokens automatically refreshed before expiry +✅ Backend APIs accept and validate bearer tokens +✅ Users can log out and credentials are cleared +✅ OAuth errors handled gracefully with user feedback +✅ Monitoring and logging capture auth flow metrics +✅ No sensitive data logged or exposed to frontend + +--- + +## 📧 Questions or Issues? + +When implementing this auth pattern: + +1. **Protocol questions?** → See AUTH-FLOW-DOCUMENTATION.md +2. **Setup questions?** → See ENTRA-ID-CONFIG-CHECKLIST.md +3. **Configuration questions?** → See ENVIRONMENT-VARIABLES-REFERENCE.md +4. **Visual understanding?** → See Architecture/Sequence diagrams +5. **Code snippets needed?** → See ENVIRONMENT-VARIABLES-REFERENCE.md snippets 1-10 + +--- + +**End of Documentation Package Summary** + +This comprehensive documentation package is complete and ready for implementation. Proceed with Phase 1 (Azure AD Setup) when team is ready. + diff --git a/packages/excel/src/lib/adapter.ts b/packages/excel/src/lib/adapter.ts index 11bbb22..d5cbbb5 100644 --- a/packages/excel/src/lib/adapter.ts +++ b/packages/excel/src/lib/adapter.ts @@ -5,6 +5,7 @@ import SelectionIndicator from "./components/selection-indicator.svelte"; import excelApiDts from "./docs/excel-officejs-api.d.ts?raw"; import { getWorkbookMetadata, navigateTo } from "./excel/api"; import { buildExcelSystemPrompt } from "./system-prompt"; +import { logToolCall } from "./telemetry-hooks"; import { createExcelTools } from "./tools"; import { getCustomCommands } from "./vfs/custom-commands"; @@ -66,15 +67,47 @@ export function createExcelAdapter(): AppAdapter { } }, - onToolResult: (_toolCallId, result, isError) => { - if (isError) return; - const dirtyRanges = parseDirtyRanges(result); - if (dirtyRanges && dirtyRanges.length > 0) { - const first = dirtyRanges[0]; - if (first.sheetId >= 0 && first.range !== "*") { - navigateTo(first.sheetId, first.range).catch(console.error); - } else if (first.sheetId >= 0) { - navigateTo(first.sheetId).catch(console.error); + onToolResult: (toolCallId, toolName, result, isError, durationMs) => { + // Parse tool result to check for success/error + let toolSuccess = !isError; + let toolErrorMessage: string | undefined; + + // Check if result contains {"success":false} + if (!isError && result) { + try { + const parsed = JSON.parse(result); + if (parsed && typeof parsed === "object" && "success" in parsed) { + toolSuccess = parsed.success === true; + if (!toolSuccess && parsed.error) { + toolErrorMessage = parsed.error; + } + } + } catch { + // Not JSON - use isError flag + } + } + + // Optional telemetry hook (can be set by implementations) + logToolCall({ + toolCallId, + toolName, + success: toolSuccess, + threwException: isError, // True if tool crashed, false if returned {"success":false} + durationMs, + errorMessage: isError ? result?.substring(0, 500) : toolErrorMessage, + resultPreview: result?.substring(0, 500), + }); + + // Navigate to modified range on success + if (toolSuccess) { + const dirtyRanges = parseDirtyRanges(result); + if (dirtyRanges && dirtyRanges.length > 0) { + const first = dirtyRanges[0]; + if (first.sheetId >= 0 && first.range !== "*") { + navigateTo(first.sheetId, first.range).catch(console.error); + } else if (first.sheetId >= 0) { + navigateTo(first.sheetId).catch(console.error); + } } } }, diff --git a/packages/excel/src/lib/system-prompt.ts b/packages/excel/src/lib/system-prompt.ts index 2073381..e78f8d7 100644 --- a/packages/excel/src/lib/system-prompt.ts +++ b/packages/excel/src/lib/system-prompt.ts @@ -53,8 +53,51 @@ EXCEL WRITE: - resize_range: Adjust column widths and row heights - modify_object: Create/update/delete charts and pivot tables +UNDO/REDO: +- undo: Programmatically reverse write operations (real Ctrl+Z) +- undo_history: View operations that can be undone + +PERMISSION & FALLBACK: +- check_write_permissions: Check if workbook allows writes +- provide_copy_paste_formulas: Generate formulas for manual entry when writes blocked + eval_officejs has access to readFile(path) → Promise, readFileBuffer(path) → Promise, and writeFile(path, content) → Promise (content: string | Uint8Array) for VFS files. +## UNDO SYSTEM - You Can Fix Mistakes! + +✅ **UNDO IS AVAILABLE**: All write operations are automatically tracked and can be reversed: +- Made a mistake? Use the undo tool to reverse it programmatically +- Accidentally overwrote data? Call undo immediately to restore it +- Not sure about a change? Make it, then undo if needed +- Check what can be undone with undo_history + +⚠️ **BEST PRACTICES**: +1. Read data before modifying to understand what you're changing +2. If uncertain, make the change and undo if it's wrong +3. Check undo_history to see what operations can be reversed + +## HANDLING WRITE FAILURES - ALWAYS PROVIDE SOLUTIONS + +⚠️ **When writes are blocked** (workbook protected, read-only mode): +1. **Don't just fail** - Excel may block direct writes due to protection +2. **Immediately offer copy-paste formulas** using provide_copy_paste_formulas +3. **Show exact formulas** the user can manually paste +4. **Guide them step-by-step** on where to paste + +**Example flow when set_cell_range fails:** +1. set_cell_range returns error "protected" +2. Immediately call: provide_copy_paste_formulas with all the formulas +3. Tell user: "Excel is blocking writes, here are formulas to paste manually" +4. Show formatted, easy-to-copy formulas + +**Why this matters:** +- Users expect solutions, not just errors +- Manual paste is a valid fallback +- Copy-paste formulas work even when Excel blocks programmatic writes +- Maintains user productivity despite technical limitations + +IMPORTANT: Always build on existing logic and data - do not delete or overwrite unless the user explicitly asks you to. Extend formulas, add to ranges, and preserve existing work. If you need to restructure, ask first. + Citations: Use markdown links with #cite: hash to reference sheets/cells. Clicking navigates there. - Sheet only: [Sheet Name](#cite:sheetId) - Cell/range: [A1:B10](#cite:sheetId!A1:B10) diff --git a/packages/excel/src/lib/telemetry-hooks.ts b/packages/excel/src/lib/telemetry-hooks.ts new file mode 100644 index 0000000..9a9e83d --- /dev/null +++ b/packages/excel/src/lib/telemetry-hooks.ts @@ -0,0 +1,100 @@ +/** + * Telemetry Hooks Interface + * + * Provides optional hooks for monitoring tool execution. + * Implementations can integrate with any monitoring service (Datadog, Application Insights, etc.) + */ + +export interface TelemetryHooks { + /** + * Called after a tool executes + * @param toolCallId - Unique ID for this tool invocation + * @param toolName - Name of the tool that was called + * @param success - Whether the tool succeeded (parses JSON result for success field) + * @param durationMs - Execution time in milliseconds (if available) + * @param threwException - True if tool crashed with exception, false if returned {"success":false} + * @param errorMessage - Error message if failed + * @param resultPreview - First 500 chars of result for debugging + */ + onToolResult?(params: { + toolCallId: string; + toolName: string; + success: boolean; + durationMs?: number; + threwException?: boolean; + errorMessage?: string; + resultPreview?: string; + }): void; + + /** + * Called when user context is set (after authentication) + */ + onUserContext?(user: { email: string; name: string; id: string }): void; + + /** + * Called on errors + */ + onError?(error: Error, context?: Record): void; +} + +/** + * Global telemetry hooks instance + * Set this during app initialization to enable monitoring + */ +export let telemetryHooks: TelemetryHooks | null = null; + +/** + * Initialize telemetry hooks + * Call this at app startup to enable monitoring + */ +export function initTelemetryHooks(hooks: TelemetryHooks): void { + telemetryHooks = hooks; + console.log("[Telemetry] Hooks initialized"); +} + +/** + * Helper to safely call hook + */ +function callHook( + hookName: T, + ...args: Parameters> +): void { + try { + const hook = telemetryHooks?.[hookName]; + if (hook && typeof hook === "function") { + // @ts-expect-error - TypeScript struggles with spread args on union types + hook(...args); + } + } catch (error) { + console.error(`[Telemetry] Hook ${hookName} failed:`, error); + } +} + +/** + * Log tool execution result + */ +export function logToolCall(params: { + toolCallId: string; + toolName: string; + success: boolean; + durationMs?: number; + threwException?: boolean; + errorMessage?: string; + resultPreview?: string; +}): void { + callHook("onToolResult", params); +} + +/** + * Log user context + */ +export function logUserContext(user: { email: string; name: string; id: string }): void { + callHook("onUserContext", user); +} + +/** + * Log error + */ +export function logError(error: Error, context?: Record): void { + callHook("onError", error, context); +} diff --git a/packages/excel/src/lib/tools/check-permissions.ts b/packages/excel/src/lib/tools/check-permissions.ts new file mode 100644 index 0000000..b14fbfa --- /dev/null +++ b/packages/excel/src/lib/tools/check-permissions.ts @@ -0,0 +1,67 @@ +import { defineTool, toolSuccess, toolError } from "./types"; + +/** + * Check workbook write permissions + * + * Before performing write operations, check if the workbook allows writes. + * If blocked, guide the user to manually enable write access. + */ +export const checkPermissionsTool = defineTool({ + name: "check_write_permissions", + description: + "Check if the workbook allows write operations. Use this before attempting writes if you suspect permission issues.", + parameters: { + type: "object", + properties: {}, + }, + execute: async () => { + try { + const status = await Excel.run(async (context) => { + const workbook = context.workbook; + const protection = workbook.protection; + + protection.load("protected"); + await context.sync(); + + const sheets = context.workbook.worksheets; + sheets.load("items/protection/protected"); + await context.sync(); + + const protectedSheets = sheets.items + .filter((sheet) => sheet.protection.protected) + .map((sheet) => sheet.name); + + return { + workbookProtected: protection.protected, + protectedSheets, + canWrite: !protection.protected && protectedSheets.length === 0, + }; + }); + + if (status.canWrite) { + return toolSuccess("✅ Workbook is writable. All write operations should work."); + } else { + let message = "⚠️ **Write restrictions detected:**\n\n"; + + if (status.workbookProtected) { + message += "- Workbook is protected\n"; + } + + if (status.protectedSheets.length > 0) { + message += `- Protected sheets: ${status.protectedSheets.join(", ")}\n`; + } + + message += "\n**To enable writes:**\n"; + message += "1. Go to Review → Unprotect Workbook/Sheet\n"; + message += "2. Or ask the user to enable write access\n\n"; + message += "I can provide copy-paste ready formulas instead."; + + return toolSuccess(message); + } + } catch (error) { + return toolError( + `Failed to check permissions: ${error instanceof Error ? error.message : String(error)}` + ); + } + }, +}); diff --git a/packages/excel/src/lib/tools/index.ts b/packages/excel/src/lib/tools/index.ts index 9c3c486..533f9ed 100644 --- a/packages/excel/src/lib/tools/index.ts +++ b/packages/excel/src/lib/tools/index.ts @@ -12,6 +12,9 @@ export { resizeRangeTool } from "./resize-range"; export { screenshotRangeTool } from "./screenshot-range"; export { searchDataTool } from "./search-data"; export { setCellRangeTool } from "./set-cell-range"; +export { undoTool, undoHistoryTool } from "./undo"; +export { checkPermissionsTool } from "./check-permissions"; +export { provideFormulasTool } from "./provide-formulas"; export { defineTool, type ToolResult, @@ -35,6 +38,9 @@ import { resizeRangeTool } from "./resize-range"; import { screenshotRangeTool } from "./screenshot-range"; import { searchDataTool } from "./search-data"; import { setCellRangeTool } from "./set-cell-range"; +import { undoHistoryTool, undoTool } from "./undo"; +import { checkPermissionsTool } from "./check-permissions"; +import { provideFormulasTool } from "./provide-formulas"; export function createExcelTools(ctx: AgentContext) { return [ @@ -56,5 +62,11 @@ export function createExcelTools(ctx: AgentContext) { resizeRangeTool, modifyObjectTool, createEvalOfficeJsTool(ctx), + // Undo/redo + undoTool, + undoHistoryTool, + // Safety & fallback tools + checkPermissionsTool, + provideFormulasTool, ]; } diff --git a/packages/excel/src/lib/tools/provide-formulas.ts b/packages/excel/src/lib/tools/provide-formulas.ts new file mode 100644 index 0000000..b260793 --- /dev/null +++ b/packages/excel/src/lib/tools/provide-formulas.ts @@ -0,0 +1,82 @@ +import { Type } from "@sinclair/typebox"; +import { defineTool, toolSuccess } from "./types"; + +/** + * Provide copy-paste ready formulas + * + * When direct writes are blocked, generate formulas that users can manually paste. + * This is a fallback when Excel protection prevents programmatic writes. + */ +export const provideFormulasTool = defineTool({ + name: "provide_copy_paste_formulas", + description: + "Generate copy-paste ready formulas for users to manually enter when direct writes are blocked. Use this as a fallback when set_cell_range fails due to protection.", + parameters: Type.Object({ + sheetName: Type.String({ description: "Target sheet name" }), + startCell: Type.String({ description: "Starting cell (e.g., A1)" }), + layout: Type.String({ + description: + "Layout description (e.g., 'Row 1 headers, Row 2 ASP Price formulas')", + }), + formulas: Type.Array( + Type.Object({ + cell: Type.String({ description: "Cell address (e.g., A1)" }), + formula: Type.String({ + description: "Formula or value (e.g., =HighLevel!G28)", + }), + description: Type.Optional( + Type.String({ description: "What this cell calculates" }) + ), + }), + { + description: "Array of formulas to provide", + } + ), + }), + execute: async (_toolCallId, params) => { + let output = `📋 **Copy-Paste Ready Formulas for "${params.sheetName}"**\n\n`; + output += `Since Excel is blocking direct writes, please manually paste these formulas:\n\n`; + output += `**Layout:** ${params.layout}\n`; + output += `**Starting at:** ${params.startCell}\n\n`; + output += `---\n\n`; + + // Group formulas by row for easier copying + const formulasByRow: { [row: string]: typeof params.formulas } = {}; + + params.formulas.forEach((f) => { + const match = f.cell.match(/^([A-Z]+)(\d+)$/); + if (match) { + const row = match[2]; + if (!formulasByRow[row]) { + formulasByRow[row] = []; + } + formulasByRow[row].push(f); + } + }); + + // Output formulas row by row + Object.keys(formulasByRow) + .sort((a, b) => parseInt(a) - parseInt(b)) + .forEach((row) => { + output += `**Row ${row}:**\n`; + formulasByRow[row].forEach((f) => { + output += `- **${f.cell}**: \`${f.formula}\``; + if (f.description) { + output += ` (${f.description})`; + } + output += "\n"; + }); + output += "\n"; + }); + + output += `---\n\n`; + output += `**How to paste:**\n`; + output += `1. Select cell ${params.startCell} on sheet "${params.sheetName}"\n`; + output += `2. Copy each formula above\n`; + output += `3. Paste into the corresponding cell\n`; + output += `4. Press Enter to confirm\n\n`; + output += `💡 **Tip:** You can paste multiple cells at once by selecting a range first.`; + + return toolSuccess(output); + }, +}); diff --git a/packages/excel/src/lib/tools/set-cell-range.ts b/packages/excel/src/lib/tools/set-cell-range.ts index 755bcf1..57de653 100644 --- a/packages/excel/src/lib/tools/set-cell-range.ts +++ b/packages/excel/src/lib/tools/set-cell-range.ts @@ -1,5 +1,10 @@ import { Type } from "@sinclair/typebox"; -import { setCellRange } from "../excel/api"; +import { setCellRange, getWorksheetById } from "../excel/api"; +import { + captureCellRangeState, + restoreCellRangeState, + undoManager, +} from "../undo"; import { defineTool, toolError, toolSuccess } from "./types"; const BorderStyleSchema = Type.Optional( @@ -125,6 +130,7 @@ export const setCellRangeTool = defineTool({ }, execute: async (_toolCallId, params) => { try { + // Execute the operation first const result = await setCellRange( params.sheetId, params.range, @@ -136,11 +142,63 @@ export const setCellRangeTool = defineTool({ allowOverwrite: params.allow_overwrite, }, ); + + // Try to capture state for undo (best effort - don't fail if this doesn't work) + try { + await Excel.run(async (context) => { + const sheet = await getWorksheetById(context, params.sheetId); + if (!sheet) { + console.log("[set_cell_range] Sheet not found for undo, skipping"); + return; + } + + sheet.load("name"); + await context.sync(); + const sheetName = sheet.name; + + // Try to capture current state for potential undo + try { + const currentState = await captureCellRangeState(sheetName, params.range); + + // Register undo operation + undoManager.registerOperation( + `Set cells in ${sheetName}!${params.range}`, + async () => { + await restoreCellRangeState(sheetName, currentState); + } + ); + } catch (stateErr) { + console.log("[set_cell_range] Could not capture state for undo:", stateErr); + } + }); + } catch (undoErr) { + // Undo registration failed - that's ok, the write still succeeded + console.log("[set_cell_range] Could not register undo:", undoErr); + } + return toolSuccess(result); } catch (error) { - const message = - error instanceof Error ? error.message : "Unknown error writing cells"; - return toolError(message); + const errorMessage = error instanceof Error ? error.message : String(error); + + // Detect permission/protection errors + if ( + errorMessage.includes("protected") || + errorMessage.includes("permission") || + errorMessage.includes("read-only") || + errorMessage.includes("restricted") + ) { + return toolError( + `❌ **Write blocked - Workbook is protected**\n\n` + + `Cannot write to cells because Excel is blocking modifications.\n\n` + + `**Solution:** Provide copy-paste ready formulas instead:\n` + + `1. Use check_write_permissions tool to diagnose\n` + + `2. Show the user the exact formulas to paste\n` + + `3. Guide them to paste manually in the target range\n\n` + + `Error: ${errorMessage}` + ); + } + + return toolError(`Failed to write cells: ${errorMessage}`); } }, }); diff --git a/packages/excel/src/lib/tools/undo.ts b/packages/excel/src/lib/tools/undo.ts new file mode 100644 index 0000000..6d66950 --- /dev/null +++ b/packages/excel/src/lib/tools/undo.ts @@ -0,0 +1,94 @@ +import { undoManager } from "../undo"; +import { defineTool, toolSuccess, toolError } from "./types"; + +/** + * Undo tool - Programmatically reverse Excel operations + * + * This provides real undo functionality by reversing tracked operations. + * All write operations (set_cell_range, modify_object, etc.) automatically + * register their undo actions. + */ +export const undoTool = defineTool({ + name: "undo", + description: + "Undo the last Excel operation(s). This programmatically reverses changes made by write operations. Use this if you made a mistake or need to revert changes.", + parameters: { + type: "object", + properties: { + steps: { + type: "number", + description: + "Number of operations to undo (default: 1). Each write operation counts as one step.", + default: 1, + }, + }, + }, + execute: async ({ steps = 1 }) => { + if (!undoManager.canUndo()) { + return toolError("No operations to undo. The undo history is empty."); + } + + const undoneOperations: string[] = []; + + try { + for (let i = 0; i < steps; i++) { + if (!undoManager.canUndo()) { + break; + } + + const description = await undoManager.undo(); + if (description) { + undoneOperations.push(description); + } + } + + if (undoneOperations.length === 0) { + return toolError("No operations were undone."); + } + + const message = `✅ Successfully undone ${undoneOperations.length} operation${undoneOperations.length > 1 ? "s" : ""}: + +${undoneOperations.map((desc, i) => `${i + 1}. ${desc}`).join("\n")} + +The data has been restored to its previous state.`; + + return toolSuccess(message); + } catch (error) { + return toolError( + `Failed to undo: ${error instanceof Error ? error.message : String(error)}\n\nPartially undone operations:\n${undoneOperations.map((desc, i) => `${i + 1}. ${desc}`).join("\n")}` + ); + } + }, +}); + +/** + * Get undo history tool - See what operations can be undone + */ +export const undoHistoryTool = defineTool({ + name: "undo_history", + description: + "View the history of operations that can be undone. Shows the most recent operations first.", + parameters: { + type: "object", + properties: {}, + }, + execute: async () => { + const history = undoManager.getUndoHistory(); + + if (history.length === 0) { + return toolSuccess("No operations in undo history."); + } + + const historyList = history + .reverse() // Most recent first + .map((op, i) => { + const date = new Date(op.timestamp); + return `${i + 1}. ${op.description} (${date.toLocaleTimeString()})`; + }) + .join("\n"); + + return toolSuccess( + `Undo History (${history.length} operation${history.length > 1 ? "s" : ""}):\n\n${historyList}\n\nUse the 'undo' tool to reverse these operations.` + ); + }, +}); diff --git a/packages/excel/src/lib/undo/capture-state.ts b/packages/excel/src/lib/undo/capture-state.ts new file mode 100644 index 0000000..416de92 --- /dev/null +++ b/packages/excel/src/lib/undo/capture-state.ts @@ -0,0 +1,218 @@ +/** + * State capture utilities for undo functionality + * + * These functions capture the current state of cells/ranges before + * operations are performed, allowing us to restore them later. + */ + +export interface CellState { + address: string; + values: any[][]; + formulas: string[][]; + numberFormats: string[][]; + formats?: { + fill?: any; + font?: any; + borders?: any; + }[][]; +} + +export interface TableState { + name: string; + address: string; + range: string; + columns: Array<{ name: string; index: number }>; + data: any[][]; + headerRowCount: number; + showTotals: boolean; +} + +export interface SheetState { + name: string; + position: number; + visibility: Excel.SheetVisibility; +} + +/** + * Capture current state of a cell range + */ +export async function captureCellRangeState( + sheetName: string, + address: string +): Promise { + return Excel.run(async (context) => { + const sheet = context.workbook.worksheets.getItem(sheetName); + const range = sheet.getRange(address); + + range.load([ + "address", + "values", + "formulas", + "numberFormat", + "format/fill", + "format/font", + "format/borders", + ]); + + await context.sync(); + + return { + address: range.address, + values: range.values, + formulas: range.formulas, + numberFormats: range.numberFormat, + formats: [[range.format]], // Simplified for now + }; + }); +} + +/** + * Restore cell range state + */ +export async function restoreCellRangeState( + sheetName: string, + state: CellState +): Promise { + return Excel.run(async (context) => { + const sheet = context.workbook.worksheets.getItem(sheetName); + const range = sheet.getRange(state.address); + + // Restore formulas (which includes values for non-formula cells) + range.formulas = state.formulas; + range.numberFormat = state.numberFormats; + + await context.sync(); + }); +} + +/** + * Capture current state of a table + */ +export async function captureTableState( + sheetName: string, + tableName: string +): Promise { + return Excel.run(async (context) => { + const sheet = context.workbook.worksheets.getItem(sheetName); + const table = sheet.tables.getItem(tableName); + + table.load([ + "name", + "range/address", + "columns", + "headerRowCount", + "showTotals", + ]); + + // Load all data including headers + const dataRange = table.getRange(); + dataRange.load(["values"]); + + await context.sync(); + + const columns = table.columns.items.map((col, index) => ({ + name: col.name, + index, + })); + + return { + name: table.name, + address: dataRange.address, + range: dataRange.address, + columns, + data: dataRange.values, + headerRowCount: table.headerRowCount, + showTotals: table.showTotals, + }; + }); +} + +/** + * Restore table state + */ +export async function restoreTableState( + sheetName: string, + state: TableState +): Promise { + return Excel.run(async (context) => { + const sheet = context.workbook.worksheets.getItem(sheetName); + + // Check if table exists + const tables = sheet.tables; + tables.load("items/name"); + await context.sync(); + + const existingTable = tables.items.find((t) => t.name === state.name); + + if (existingTable) { + // Table exists, restore its data + existingTable.load("range"); + await context.sync(); + + const tableRange = existingTable.getRange(); + tableRange.values = state.data; + } else { + // Table doesn't exist, recreate it + const range = sheet.getRange(state.range); + range.values = state.data; + + const newTable = sheet.tables.add(state.range, true); + newTable.name = state.name; + newTable.showTotals = state.showTotals; + + // Rename columns + newTable.load("columns"); + await context.sync(); + + state.columns.forEach((col, index) => { + if (newTable.columns.items[index]) { + newTable.columns.items[index].name = col.name; + } + }); + } + + await context.sync(); + }); +} + +/** + * Capture sheet state + */ +export async function captureSheetState( + sheetName: string +): Promise { + return Excel.run(async (context) => { + const sheet = context.workbook.worksheets.getItem(sheetName); + sheet.load(["name", "position", "visibility"]); + await context.sync(); + + return { + name: sheet.name, + position: sheet.position, + visibility: sheet.visibility, + }; + }); +} + +/** + * Delete a sheet (for undo of sheet creation) + */ +export async function deleteSheet(sheetName: string): Promise { + return Excel.run(async (context) => { + const sheet = context.workbook.worksheets.getItem(sheetName); + sheet.delete(); + await context.sync(); + }); +} + +/** + * Recreate a sheet (for undo of sheet deletion) + */ +export async function recreateSheet(state: SheetState): Promise { + return Excel.run(async (context) => { + const sheet = context.workbook.worksheets.add(state.name); + sheet.position = state.position; + sheet.visibility = state.visibility; + await context.sync(); + }); +} diff --git a/packages/excel/src/lib/undo/index.ts b/packages/excel/src/lib/undo/index.ts new file mode 100644 index 0000000..aab735f --- /dev/null +++ b/packages/excel/src/lib/undo/index.ts @@ -0,0 +1,12 @@ +export { undoManager } from "./undo-manager"; +export type { UndoOperation } from "./undo-manager"; +export { + captureCellRangeState, + restoreCellRangeState, + captureTableState, + restoreTableState, + captureSheetState, + deleteSheet, + recreateSheet, +} from "./capture-state"; +export type { CellState, TableState, SheetState } from "./capture-state"; diff --git a/packages/excel/src/lib/undo/undo-manager.ts b/packages/excel/src/lib/undo/undo-manager.ts new file mode 100644 index 0000000..3dc9145 --- /dev/null +++ b/packages/excel/src/lib/undo/undo-manager.ts @@ -0,0 +1,119 @@ +/** + * Undo Manager for Excel Operations + * + * Tracks all write operations and allows reversing them programmatically. + * This provides real Ctrl+Z functionality for the AI agent. + */ + +export interface UndoOperation { + id: string; + timestamp: number; + description: string; + revert: () => Promise; +} + +class UndoManager { + private undoStack: UndoOperation[] = []; + private redoStack: UndoOperation[] = []; + private maxStackSize = 50; + + /** + * Register an operation that can be undone + */ + registerOperation(description: string, revert: () => Promise): string { + const operation: UndoOperation = { + id: crypto.randomUUID(), + timestamp: Date.now(), + description, + revert, + }; + + this.undoStack.push(operation); + + // Clear redo stack when new operation is added + this.redoStack = []; + + // Limit stack size + if (this.undoStack.length > this.maxStackSize) { + this.undoStack.shift(); + } + + console.log(`[UndoManager] Registered: ${description}`); + return operation.id; + } + + /** + * Undo the last operation + */ + async undo(): Promise { + const operation = this.undoStack.pop(); + if (!operation) { + return null; + } + + console.log(`[UndoManager] Undoing: ${operation.description}`); + + try { + await operation.revert(); + this.redoStack.push(operation); + return operation.description; + } catch (error) { + // If revert fails, put it back on the stack + this.undoStack.push(operation); + throw error; + } + } + + /** + * Redo the last undone operation + */ + async redo(): Promise { + const operation = this.redoStack.pop(); + if (!operation) { + return null; + } + + console.log(`[UndoManager] Redoing: ${operation.description}`); + + // For redo, we need to re-execute the original operation + // This is more complex and requires storing the forward action too + // For now, we'll just move it back to undo stack + this.undoStack.push(operation); + return operation.description; + } + + /** + * Get undo history + */ + getUndoHistory(): Array<{ description: string; timestamp: number }> { + return this.undoStack.map((op) => ({ + description: op.description, + timestamp: op.timestamp, + })); + } + + /** + * Check if undo is available + */ + canUndo(): boolean { + return this.undoStack.length > 0; + } + + /** + * Check if redo is available + */ + canRedo(): boolean { + return this.redoStack.length > 0; + } + + /** + * Clear all history + */ + clear(): void { + this.undoStack = []; + this.redoStack = []; + } +} + +// Global undo manager instance +export const undoManager = new UndoManager(); diff --git a/packages/sdk/src/storage/db.ts b/packages/sdk/src/storage/db.ts index b4eeab1..2945684 100644 --- a/packages/sdk/src/storage/db.ts +++ b/packages/sdk/src/storage/db.ts @@ -47,34 +47,56 @@ interface OfficeAgentsSchema extends DBSchema { let dbPromise: Promise> | null = null; let dbKey: string | null = null; +let useMemoryFallback = false; -function getDb( +// NOTE: In-memory fallback for when IndexedDB is blocked (e.g., PowerPoint Online) +const memoryStore: { + sessions: Map; + vfsFiles: Map; + skillFiles: Map; +} = { + sessions: new Map(), + vfsFiles: new Map(), + skillFiles: new Map(), +}; + +async function getDb( ns: StorageNamespace, -): Promise> { +): Promise | null> { + if (useMemoryFallback) return null; + const key = `${ns.dbName}@${ns.dbVersion}`; if (dbPromise && dbKey === key) return dbPromise; dbKey = key; - dbPromise = openDB(ns.dbName, ns.dbVersion, { - upgrade(db) { - if (!db.objectStoreNames.contains("sessions")) { - const sessions = db.createObjectStore("sessions", { keyPath: "id" }); - sessions.createIndex("workbookId", "workbookId"); - sessions.createIndex("updatedAt", "updatedAt"); - } - if (!db.objectStoreNames.contains("vfsFiles")) { - const vfsFiles = db.createObjectStore("vfsFiles", { keyPath: "id" }); - vfsFiles.createIndex("sessionId", "sessionId"); - } - if (!db.objectStoreNames.contains("skillFiles")) { - const skillFiles = db.createObjectStore("skillFiles", { - keyPath: "id", - }); - skillFiles.createIndex("skillName", "skillName"); - } - }, - }); - return dbPromise; + try { + dbPromise = openDB(ns.dbName, ns.dbVersion, { + upgrade(db) { + if (!db.objectStoreNames.contains("sessions")) { + const sessions = db.createObjectStore("sessions", { keyPath: "id" }); + sessions.createIndex("workbookId", "workbookId"); + sessions.createIndex("updatedAt", "updatedAt"); + } + if (!db.objectStoreNames.contains("vfsFiles")) { + const vfsFiles = db.createObjectStore("vfsFiles", { keyPath: "id" }); + vfsFiles.createIndex("sessionId", "sessionId"); + } + if (!db.objectStoreNames.contains("skillFiles")) { + const skillFiles = db.createObjectStore("skillFiles", { + keyPath: "id", + }); + skillFiles.createIndex("skillName", "skillName"); + } + }, + }); + return await dbPromise; + } catch (error) { + // NOTE: IndexedDB blocked (PowerPoint Online with tracking protection) + console.warn("[DB] IndexedDB unavailable, using in-memory fallback:", error); + useMemoryFallback = true; + dbPromise = null; + return null; + } } function extractUserText(msg: AgentMessage): string | null { @@ -130,6 +152,18 @@ export async function listSessions( workbookId: string, ): Promise { const db = await getDb(ns); + + // NOTE: Memory fallback + if (!db) { + const sessions = [...memoryStore.sessions.values()] + .filter(s => s.workbookId === workbookId); + for (const s of sessions) { + if (!s.agentMessages) s.agentMessages = []; + } + sessions.sort((a, b) => b.updatedAt - a.updatedAt); + return sessions; + } + const sessions = await db.getAllFromIndex( "sessions", "workbookId", @@ -157,6 +191,13 @@ export async function createSession( createdAt: now, updatedAt: now, }; + + // NOTE: Memory fallback + if (!db) { + memoryStore.sessions.set(session.id, session); + return session; + } + await db.add("sessions", session); return session; } @@ -166,6 +207,16 @@ export async function getSession( sessionId: string, ): Promise { const db = await getDb(ns); + + // NOTE: Memory fallback + if (!db) { + const session = memoryStore.sessions.get(sessionId); + if (session && !session.agentMessages) { + session.agentMessages = []; + } + return session; + } + const session = await db.get("sessions", sessionId); if (session && !session.agentMessages) { session.agentMessages = []; @@ -185,6 +236,29 @@ export async function saveSession( agentMessages.length, ); const db = await getDb(ns); + + // NOTE: Memory fallback + if (!db) { + const session = memoryStore.sessions.get(sessionId); + if (!session) { + console.error("[DB] Session not found for save:", sessionId); + return; + } + let name = session.name; + if (name === "New Chat") { + const derivedName = deriveSessionName(agentMessages); + if (derivedName) name = derivedName; + } + memoryStore.sessions.set(sessionId, { + ...session, + agentMessages, + name, + updatedAt: Date.now(), + }); + console.log("[DB] saveSession complete (memory)"); + return; + } + const session = await db.get("sessions", sessionId); if (!session) { console.error("[DB] Session not found for save:", sessionId); @@ -210,6 +284,16 @@ export async function renameSession( name: string, ): Promise { const db = await getDb(ns); + + // NOTE: Memory fallback + if (!db) { + const session = memoryStore.sessions.get(sessionId); + if (session) { + memoryStore.sessions.set(sessionId, { ...session, name }); + } + return; + } + const session = await db.get("sessions", sessionId); if (session) { await db.put("sessions", { ...session, name }); @@ -221,6 +305,13 @@ export async function deleteSession( sessionId: string, ): Promise { const db = await getDb(ns); + + // NOTE: Memory fallback + if (!db) { + memoryStore.sessions.delete(sessionId); + return; + } + await db.delete("sessions", sessionId); } @@ -244,6 +335,28 @@ export async function saveVfsFiles( ): Promise { console.log("[DB] saveVfsFiles:", sessionId, "files:", files.length); const db = await getDb(ns); + + // NOTE: Memory fallback + if (!db) { + // Delete existing files for this session + for (const [key, file] of memoryStore.vfsFiles) { + if (file.sessionId === sessionId) { + memoryStore.vfsFiles.delete(key); + } + } + // Add new files + for (const f of files) { + const id = `${sessionId}:${f.path}`; + memoryStore.vfsFiles.set(id, { + id, + sessionId, + path: f.path, + data: f.data, + }); + } + return; + } + const tx = db.transaction("vfsFiles", "readwrite"); const store = tx.store; const existing = await store.index("sessionId").getAllKeys(sessionId); @@ -266,6 +379,15 @@ export async function loadVfsFiles( sessionId: string, ): Promise<{ path: string; data: Uint8Array }[]> { const db = await getDb(ns); + + // NOTE: Memory fallback + if (!db) { + const rows = [...memoryStore.vfsFiles.values()] + .filter(f => f.sessionId === sessionId); + console.log("[DB] loadVfsFiles (memory):", sessionId, "files:", rows.length); + return rows.map((r) => ({ path: r.path, data: r.data })); + } + const rows = await db.getAllFromIndex("vfsFiles", "sessionId", sessionId); console.log("[DB] loadVfsFiles:", sessionId, "files:", rows.length); return rows.map((r) => ({ path: r.path, data: r.data })); @@ -276,6 +398,17 @@ export async function deleteVfsFiles( sessionId: string, ): Promise { const db = await getDb(ns); + + // NOTE: Memory fallback + if (!db) { + for (const [key, file] of memoryStore.vfsFiles) { + if (file.sessionId === sessionId) { + memoryStore.vfsFiles.delete(key); + } + } + return; + } + const tx = db.transaction("vfsFiles", "readwrite"); const keys = await tx.store.index("sessionId").getAllKeys(sessionId); for (const key of keys) { @@ -290,6 +423,26 @@ export async function saveSkillFiles( files: { path: string; data: Uint8Array }[], ): Promise { const db = await getDb(ns); + + // NOTE: Memory fallback + if (!db) { + for (const [key, file] of memoryStore.skillFiles) { + if (file.skillName === skillName) { + memoryStore.skillFiles.delete(key); + } + } + for (const f of files) { + const id = `${skillName}:${f.path}`; + memoryStore.skillFiles.set(id, { + id, + skillName, + path: f.path, + data: f.data, + }); + } + return; + } + const tx = db.transaction("skillFiles", "readwrite"); const store = tx.store; const existing = await store.index("skillName").getAllKeys(skillName); @@ -312,6 +465,14 @@ export async function loadSkillFiles( skillName: string, ): Promise<{ path: string; data: Uint8Array }[]> { const db = await getDb(ns); + + // NOTE: Memory fallback + if (!db) { + const rows = [...memoryStore.skillFiles.values()] + .filter(f => f.skillName === skillName); + return rows.map((r) => ({ path: r.path, data: r.data })); + } + const rows = await db.getAllFromIndex("skillFiles", "skillName", skillName); return rows.map((r) => ({ path: r.path, data: r.data })); } @@ -320,6 +481,17 @@ export async function loadAllSkillFiles( ns: StorageNamespace, ): Promise<{ skillName: string; path: string; data: Uint8Array }[]> { const db = await getDb(ns); + + // NOTE: Memory fallback + if (!db) { + const rows = [...memoryStore.skillFiles.values()]; + return rows.map((r) => ({ + skillName: r.skillName, + path: r.path, + data: r.data, + })); + } + const rows = await db.getAll("skillFiles"); return rows.map((r) => ({ skillName: r.skillName, @@ -333,6 +505,17 @@ export async function deleteSkillFiles( skillName: string, ): Promise { const db = await getDb(ns); + + // NOTE: Memory fallback + if (!db) { + for (const [key, file] of memoryStore.skillFiles) { + if (file.skillName === skillName) { + memoryStore.skillFiles.delete(key); + } + } + return; + } + const tx = db.transaction("skillFiles", "readwrite"); const keys = await tx.store.index("skillName").getAllKeys(skillName); for (const key of keys) { @@ -343,6 +526,13 @@ export async function deleteSkillFiles( export async function listSkillNames(ns: StorageNamespace): Promise { const db = await getDb(ns); + + // NOTE: Memory fallback + if (!db) { + const names = new Set([...memoryStore.skillFiles.values()].map((r) => r.skillName)); + return [...names].sort(); + } + const rows = await db.getAll("skillFiles"); const names = new Set(rows.map((r) => r.skillName)); return [...names].sort();