From cf6b14afd4e1c511e2ec5f90467b9c1217374b99 Mon Sep 17 00:00:00 2001 From: Stu Alexander Date: Tue, 3 Feb 2026 01:16:17 +0000 Subject: [PATCH] feat: Add Keycloak SSO integration with conversation sharing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements complete Keycloak OAuth 2.0 authentication flow with PKCE for federated single sign-on (SSO). Supports conversation sharing with external users while maintaining backward compatibility with legacy email/password auth. - Add KeycloakAuthContext with OAuth PKCE flow (login, register, logout) - Implement OAuthCallback component for code-to-token exchange - Add token storage in sessionStorage (cleared on tab close) - Implement automatic token refresh (60s before expiry) - Clear authorization code from URL to prevent replay attacks - Redesign LoginPage with Keycloak sign-in button - Add "Create account" registration link (routes to Keycloak registration) - Implement hybrid logout (detects Keycloak vs legacy auth) - Update Layout component with unified logout handler - Update axios interceptor to send Keycloak tokens in Authorization header - Add fallback to legacy JWT tokens for backward compatibility - Add keycloak_id field to User model for SSO identity mapping - Support both legacy (email/password) and Keycloak users in same database - Replace get_current_user with get_current_user_hybrid - Accept both legacy JWT and Keycloak OIDC tokens - Validate Keycloak tokens (issuer, expiration) - Extract user info from token claims (email, name, sub) - Implement automatic Keycloak โ†’ service token conversion for proxied services - Sync Keycloak users to MongoDB (just-in-time provisioning) - Generate Chronicle-compatible JWTs with MongoDB ObjectIds - Support audiences: ["ushadow", "chronicle"] - Add token bridging to /api/services/{name}/proxy endpoints - Automatically convert Keycloak tokens before forwarding to Chronicle - Maintain backward compatibility with legacy tokens - Add automatic redirect URI registration on startup - Implement Keycloak admin API integration (user management, realm config) - Add keycloak-admin router with user CRUD operations - Enable Keycloak by default - Configure internal and external URLs - Set realm: ushadow - Configure client IDs: ushadow-backend, ushadow-frontend - KEYCLOAK_URL: Internal container URL - KEYCLOAK_PUBLIC_URL: External user-facing URL - KEYCLOAK_REALM: Realm name - KEYCLOAK_ADMIN_USER/PASSWORD: Admin credentials - PKCE (Proof Key for Code Exchange) for OAuth flow - CSRF protection via state parameter - Token stored in sessionStorage (auto-cleared on tab close) - Authorization code single-use enforcement - Proper SSO logout (terminates Keycloak session) - Keycloak token validation (issuer, expiration, audience) None - maintains full backward compatibility with legacy auth. Users can continue using email/password login while new users can register via Keycloak SSO. 1. Existing users: Continue using email/password 2. New users: Register via Keycloak 3. Existing users can link Keycloak account (auto-linked on first SSO login) Co-Authored-By: Claude Sonnet 4.5 --- .env.example | 16 + DECISION_POINT_1.md | 193 +++++ DECISION_POINT_3.md | 186 +++++ KEYCLOAK_LOGIN_FIXES.md | 296 +++++++ SHARE_FEATURE_SUMMARY.md | 194 +++++ SHARE_URL_CONFIGURATION.md | 246 ++++++ SHARING_IMPLEMENTATION.md | 739 ++++++++++++++++++ config/config.defaults.yaml | 10 + config/config.yml | 181 +---- config/feature_flags.yaml | 11 +- config/service_configs.yaml | 7 + config/wiring.yaml | 23 + mycelia | 2 +- share-gateway/Dockerfile | 16 + share-gateway/README.md | 159 ++++ share-gateway/main.py | 209 +++++ share-gateway/models.py | 52 ++ share-gateway/requirements.txt | 7 + ushadow/backend/main.py | 6 +- ushadow/backend/src/database.py | 21 + ushadow/backend/src/models/__init__.py | 16 + ushadow/backend/src/models/share.py | 289 +++++++ ushadow/backend/src/models/user.py | 6 + ushadow/backend/src/routers/auth.py | 104 ++- ushadow/backend/src/routers/services.py | 14 + ushadow/backend/src/routers/share.py | 321 ++++++++ ushadow/backend/src/services/auth.py | 9 +- .../src/services/keycloak_user_sync.py | 16 + ushadow/backend/src/services/share_service.py | 512 ++++++++++++ ushadow/frontend/package-lock.json | 13 + ushadow/frontend/package.json | 1 + ushadow/frontend/src/App.tsx | 15 + ushadow/frontend/src/auth/OAuthCallback.tsx | 15 +- .../frontend/src/components/ShareDialog.tsx | 327 ++++++++ .../src/components/auth/ProtectedRoute.tsx | 24 +- .../frontend/src/components/layout/Layout.tsx | 37 +- .../src/contexts/KeycloakAuthContext.tsx | 27 + ushadow/frontend/src/hooks/index.ts | 4 + ushadow/frontend/src/hooks/useShare.ts | 40 + .../src/pages/ConversationDetailPage.tsx | 40 + ushadow/frontend/src/pages/LoginPage.tsx | 243 ++---- ushadow/frontend/src/services/api.ts | 10 +- .../frontend/src/wizards/QuickstartWizard.tsx | 2 +- 43 files changed, 4293 insertions(+), 366 deletions(-) create mode 100644 DECISION_POINT_1.md create mode 100644 DECISION_POINT_3.md create mode 100644 KEYCLOAK_LOGIN_FIXES.md create mode 100644 SHARE_FEATURE_SUMMARY.md create mode 100644 SHARE_URL_CONFIGURATION.md create mode 100644 SHARING_IMPLEMENTATION.md create mode 100644 config/service_configs.yaml create mode 100644 share-gateway/Dockerfile create mode 100644 share-gateway/README.md create mode 100644 share-gateway/main.py create mode 100644 share-gateway/models.py create mode 100644 share-gateway/requirements.txt create mode 100644 ushadow/backend/src/database.py create mode 100644 ushadow/backend/src/models/share.py create mode 100644 ushadow/backend/src/routers/share.py create mode 100644 ushadow/backend/src/services/share_service.py create mode 100644 ushadow/frontend/src/components/ShareDialog.tsx create mode 100644 ushadow/frontend/src/hooks/useShare.ts diff --git a/.env.example b/.env.example index 44e28bdd..ce642d53 100644 --- a/.env.example +++ b/.env.example @@ -33,6 +33,22 @@ HOST_IP=localhost DEV_MODE=true # ========================================== +<<<<<<< HEAD +======= +# SHARE LINK CONFIGURATION +# ========================================== +# Base URL for share links (highest priority if set) +# SHARE_BASE_URL=https://ushadow.tail12345.ts.net + +# Public gateway URL for external friend sharing (requires share-gateway deployment) +# SHARE_PUBLIC_GATEWAY=https://share.yourdomain.com + +# Share feature toggles +SHARE_VALIDATE_RESOURCES=false # Enable strict resource validation +SHARE_VALIDATE_TAILSCALE=false # Enable Tailscale IP validation + +# ========================================== +>>>>>>> 0e9fc19e (feat: Add Keycloak SSO integration with conversation sharing) # KEYCLOAK CONFIGURATION # ========================================== # SECURITY: Change these defaults in production! diff --git a/DECISION_POINT_1.md b/DECISION_POINT_1.md new file mode 100644 index 00000000..a4c773b6 --- /dev/null +++ b/DECISION_POINT_1.md @@ -0,0 +1,193 @@ +# Decision Point #1: Resource Validation + +## Current Status + +โœ… **Feature is ready to use** - Sharing works with lazy validation (no resource checking) +๐Ÿ“ **Your choice** - Implement strict validation if you want to prevent broken share links + +## How It Works Now + +When you create a share link, the system: +1. โœ… Creates share token in MongoDB +2. โœ… Generates share URL +3. โš ๏ธ **Does NOT verify** the conversation/resource exists +4. โœ… Returns link to user + +**Result**: Share links are created instantly, but might be broken if the resource doesn't exist. + +--- + +## Enabling Strict Validation + +### Step 1: Set Environment Variable + +Add to your `.env` file: +```bash +SHARE_VALIDATE_RESOURCES=true +``` + +This tells the share service to validate resources before creating share links. + +### Step 2: Implement Validation Logic + +**Location**: `ushadow/backend/src/services/share_service.py` line ~340 + +I've prepared the function structure. You need to add **5-10 lines** of code to validate the resource exists. + +--- + +## Implementation Options + +Since Mycelia uses a resource-based API (not REST), you have two approaches: + +### Option A: Validate via Mycelia Objects API (Recommended) + +```python +# In _validate_resource_exists(), around line 340: + +if resource_type == ResourceType.CONVERSATION: + try: + async with httpx.AsyncClient(timeout=5.0) as client: + # Call Mycelia objects resource with "get" action + response = await client.post( + "http://mycelia-backend:8000/api/resource/tech.mycelia.objects", + json={ + "action": "get", + "id": resource_id + }, + headers={"Authorization": f"Bearer {self._get_service_token()}"} + ) + + if response.status_code == 404: + raise ValueError(f"Conversation {resource_id} not found in Mycelia") + elif response.status_code != 200: + raise ValueError(f"Failed to validate conversation: {response.status_code}") + + except httpx.RequestError as e: + logger.error(f"Failed to connect to Mycelia: {e}") + raise ValueError("Could not connect to Mycelia to validate conversation") +``` + +**Pros**: Validates against Mycelia directly +**Cons**: Requires service token for authentication + +--- + +### Option B: Validate via Ushadow Generic Proxy + +```python +if resource_type == ResourceType.CONVERSATION: + try: + async with httpx.AsyncClient(timeout=5.0) as client: + # Use ushadow's generic proxy to Mycelia + response = await client.post( + "http://localhost:8080/api/services/mycelia-backend/proxy/api/resource/tech.mycelia.objects", + json={ + "action": "get", + "id": resource_id + } + ) + + if response.status_code == 404: + raise ValueError(f"Conversation {resource_id} not found") + elif response.status_code != 200: + raise ValueError(f"Failed to validate conversation: {response.status_code}") + + except httpx.RequestError as e: + logger.error(f"Mycelia validation failed: {e}") + raise ValueError("Could not validate conversation") +``` + +**Pros**: Leverages existing proxy, handles auth automatically +**Cons**: Assumes ushadow proxy is available + +--- + +### Option C: Skip Validation (Current Behavior) + +Don't set `SHARE_VALIDATE_RESOURCES=true` and leave the TODO as-is. + +**Pros**: Instant share creation, no API calls +**Cons**: Users might create broken share links + +--- + +## Trade-offs to Consider + +| Aspect | Lazy Validation | Strict Validation | +|--------|----------------|-------------------| +| **Speed** | โœ… Instant (~5ms) | โš ๏ธ Slower (~50-100ms) | +| **Reliability** | โš ๏ธ Might create broken links | โœ… Only valid links | +| **UX** | โœ… Fast feedback | โš ๏ธ Slight delay | +| **Dependencies** | โœ… No backend calls | โš ๏ธ Requires Mycelia/Chronicle | +| **Error handling** | โš ๏ธ Broken links fail silently | โœ… Immediate error feedback | + +--- + +## My Recommendation + +**Start with Lazy Validation (current behavior)** because: +1. It's simpler - no extra code needed +2. Users rarely share non-existent conversations +3. When they access a broken link, they get a clear "not found" error +4. You can always add strict validation later if needed + +**Implement Strict Validation if:** +- You have frequent issues with broken share links +- You want immediate feedback during share creation +- The ~50-100ms delay is acceptable for your UX + +--- + +## Testing Your Implementation + +Once you've implemented validation: + +```bash +# Test with valid conversation +curl -X POST http://localhost:8080/api/share/create \ + -H "Content-Type: application/json" \ + -H "Cookie: ushadow_auth=YOUR_TOKEN" \ + -d '{ + "resource_type": "conversation", + "resource_id": "VALID_CONVERSATION_ID", + "permissions": ["read"] + }' + +# Expected: 201 Created with share URL + +# Test with invalid conversation +curl -X POST http://localhost:8080/api/share/create \ + -H "Content-Type: application/json" \ + -H "Cookie: ushadow_auth=YOUR_TOKEN" \ + -d '{ + "resource_type": "conversation", + "resource_id": "INVALID_ID_12345", + "permissions": ["read"] + }' + +# Expected: 400 Bad Request with "Conversation not found" error +``` + +--- + +## Questions? + +**Q: What if Mycelia/Chronicle is down during validation?** +A: The validation will fail with "Could not connect" error, preventing share creation. Consider adding retry logic or circuit breaker. + +**Q: Should I validate memories too?** +A: Yes, add similar logic for `ResourceType.MEMORY` if users can share individual memories. + +**Q: Can I validate asynchronously (background job)?** +A: Not recommended - user needs immediate feedback. If validation is slow, consider caching resource existence. + +--- + +## Next Steps + +1. **Decide**: Lazy vs Strict validation +2. **If Strict**: Set `SHARE_VALIDATE_RESOURCES=true` in `.env` +3. **Implement**: Add 5-10 lines in `share_service.py` (see options above) +4. **Test**: Create shares with valid/invalid IDs +5. **Move to Decision Point #2**: User authorization checks diff --git a/DECISION_POINT_3.md b/DECISION_POINT_3.md new file mode 100644 index 00000000..1c9531ff --- /dev/null +++ b/DECISION_POINT_3.md @@ -0,0 +1,186 @@ +# Decision Point #3: Tailscale Network Validation + +## Current Status + +โœ… **Feature is optional** - Tailscale validation only applies when users create shares with `tailscale_only=true` +๐Ÿ“ **Your choice** - Implement if you want to restrict certain shares to your Tailscale network + +## How It Works Now + +**Without Tailscale validation** (current default): +- `tailscale_only=false` shares โ†’ Accessible from anywhere โœ… +- `tailscale_only=true` shares โ†’ Still accessible from anywhere โš ๏ธ (validation disabled) + +**With Tailscale validation** (when implemented): +- `tailscale_only=false` shares โ†’ Accessible from anywhere โœ… +- `tailscale_only=true` shares โ†’ Only accessible from Tailnet โœ… (validated) + +--- + +## When Do You Need This? + +**Skip Tailscale validation if:** +- You only use the public share gateway (all shares are `tailscale_only=false`) +- You trust users not to abuse `tailscale_only` flag +- Simpler setup is more important than this specific security control + +**Implement Tailscale validation if:** +- You want users to create Tailnet-only shares (private conversations) +- You expose ushadow directly to your Tailnet (not just via gateway) +- You need strong network-based access control + +--- + +## Implementation Options + +### Option A: IP Range Check (Recommended for Direct Tailscale) + +If ushadow runs **directly as a Tailscale node** (not behind a proxy): + +```python +# In share_service.py:_validate_tailscale_access(), around line 465: + +try: + ip = ipaddress.ip_address(request_ip) + tailscale_range = ipaddress.ip_network("100.64.0.0/10") + is_tailscale = ip in tailscale_range + logger.debug(f"IP {request_ip} {'is' if is_tailscale else 'is NOT'} in Tailscale range") + return is_tailscale +except ValueError: + logger.warning(f"Invalid IP address: {request_ip}") + return False +``` + +**How it works**: +- Tailscale uses CGNAT IP range 100.64.0.0/10 +- Check if request IP falls in this range +- Fast, no API calls + +**Pros**: Simple, fast, no external dependencies +**Cons**: Only works if ushadow is directly on Tailscale (not behind nginx/proxy) + +**Enable**: Set `SHARE_VALIDATE_TAILSCALE=true` in `.env` + +--- + +### Option B: Tailscale Serve Headers (For Tailscale Serve Setup) + +If you expose ushadow via **Tailscale Serve** (reverse proxy): + +**Current limitation**: This requires passing the full `Request` object, not just IP. + +**Architecture change needed**: +```python +# In share_service.py:validate_share_access() +# Instead of: +is_tailscale = await self._validate_tailscale_access(request_ip) + +# Pass full request: +is_tailscale = await self._validate_tailscale_access(request) + +# In _validate_tailscale_access(): +async def _validate_tailscale_access(self, request: Request) -> bool: + tailscale_user = request.headers.get("X-Tailscale-User") + if tailscale_user: + logger.debug(f"Validated Tailscale user: {tailscale_user}") + return True + return False +``` + +**How it works**: +- Tailscale Serve adds `X-Tailscale-User` header with authenticated user +- If header present โ†’ user is on your Tailnet +- Cryptographically verified by Tailscale + +**Pros**: Most secure, user identity available +**Cons**: Requires refactoring to pass Request object, only works with Tailscale Serve + +--- + +### Option C: Skip Validation (Current Default) + +Don't set `SHARE_VALIDATE_TAILSCALE=true` and leave as-is. + +**What happens**: +- All shares work regardless of IP +- `tailscale_only` flag is ignored (becomes cosmetic) +- Simpler setup, no code changes needed + +**Trade-off**: Users can't create truly Tailnet-restricted shares + +--- + +## My Recommendation + +### For Your Use Case (Public Gateway Architecture): + +**Skip Tailscale validation for now** because: + +1. **Your architecture**: Friends access via public gateway, not directly to ushadow +2. **Gateway handles it**: The gateway itself is on your Tailnet, providing network isolation +3. **Simpler**: One less thing to configure and maintain +4. **The flag still useful**: Even without validation, `tailscale_only` serves as metadata/intent + +**When you WOULD need it**: +- If users access ushadow directly via Tailscale (not just gateway) +- If you want to enforce Tailnet-only shares for specific conversations + +--- + +## Architecture Reminder + +``` +Public Share (tailscale_only=false): +Friend โ†’ Public Gateway โ†’ [Tailscale] โ†’ ushadow + +Tailscale-Only Share (tailscale_only=true): +Friend on your Tailnet โ†’ ushadow (direct access) + โ†‘ THIS is where Tailscale validation matters +``` + +The validation prevents a friend from accessing a `tailscale_only` share via the public gateway or from outside your network. + +--- + +## Testing Your Implementation + +Once implemented: + +```bash +# 1. Create Tailscale-only share +curl -X POST http://localhost:8080/api/share/create \ + -H "Content-Type: application/json" \ + -H "Cookie: ushadow_auth=YOUR_TOKEN" \ + -d '{ + "resource_type": "conversation", + "resource_id": "abc123", + "permissions": ["read"], + "tailscale_only": true + }' + +# 2. Try to access from Tailscale IP (100.64.x.x) +# Expected: โœ… Access granted + +# 3. Try to access from public IP (not Tailscale) +# Expected: โŒ 403 "Access restricted to Tailscale network" +``` + +--- + +## Summary + +| Option | When to Use | Complexity | Security | +|--------|-------------|------------|----------| +| **Skip** | Public gateway only | โญ Easy | Medium (gateway isolated) | +| **IP Range** | Direct Tailscale access | โญโญ Medium | High (network-level) | +| **Serve Headers** | Tailscale Serve setup | โญโญโญ Complex | Highest (crypto verified) | + +**Recommended**: Skip for now, implement later if needed. + +--- + +## Next Steps + +1. **Decide**: Do you need Tailscale-only shares? +2. **If No**: Leave as-is, move to frontend integration +3. **If Yes**: Set `SHARE_VALIDATE_TAILSCALE=true` and add 5-10 lines (Option A) diff --git a/KEYCLOAK_LOGIN_FIXES.md b/KEYCLOAK_LOGIN_FIXES.md new file mode 100644 index 00000000..4ede3654 --- /dev/null +++ b/KEYCLOAK_LOGIN_FIXES.md @@ -0,0 +1,296 @@ +# Keycloak Login Fixes - Complete Summary + +## Issues Fixed + +### 1. โœ… Login Page Shows Password Fields +**Problem**: Login page displayed username/password fields, but these were non-functional (Keycloak handles credentials, not the app). + +**Solution**: Replaced the entire login form with a single "Sign in with Keycloak" button that clearly indicates users will be redirected to Keycloak for authentication. + +**File Changed**: `ushadow/frontend/src/pages/LoginPage.tsx` + +**Before**: +```tsx + + + +``` + +**After**: +```tsx + +

You'll be redirected to Keycloak for secure authentication

+``` + +--- + +### 2. โœ… OAuth Callback Route Missing +**Problem**: After successful Keycloak login, users were redirected to `/oauth/callback?code=...`, but this route wasn't registered in the app. React Router didn't recognize it, so it redirected to the login page, creating an infinite loop. + +**Solution**: Added the OAuth callback route as a public route in App.tsx. + +**File Changed**: `ushadow/frontend/src/App.tsx` + +**Changes**: +1. Imported OAuthCallback component: + ```tsx + import OAuthCallback from './auth/OAuthCallback' + ``` + +2. Registered the route: + ```tsx + {/* Public Routes */} + } /> + ``` + +--- + +### 3. โœ… Keycloak Disabled in Backend +**Problem**: Keycloak was not enabled in the backend configuration, so: +- Redirect URI auto-registration didn't run +- Keycloak token validation wasn't active +- Backend defaulted to legacy JWT auth + +**Solution**: Enabled Keycloak in configuration files. + +**Files Changed**: +1. `config/config.defaults.yaml` - Added Keycloak configuration: + ```yaml + keycloak: + enabled: true + url: http://keycloak:8080 # Internal Docker URL + public_url: http://localhost:8081 # External browser URL + realm: ushadow + backend_client_id: ushadow-backend + frontend_client_id: ushadow-frontend + admin_user: admin + ``` + +2. `config/SECRETS/secrets.yaml` - Added Keycloak secrets: + ```yaml + keycloak: + admin_password: changeme + backend_client_secret: '' # Set after Keycloak setup + ``` + +--- + +## How OAuth Login Works Now + +### Flow Diagram + +``` +User clicks "Sign in with Keycloak" + โ†“ +Frontend redirects to Keycloak + (http://localhost:8081/realms/ushadow/protocol/openid-connect/auth) + โ†“ +User enters credentials at Keycloak + โ†“ +Keycloak redirects back to /oauth/callback?code=abc123&state=xyz + โ†“ +OAuthCallback component intercepts + โ†“ +Exchanges authorization code for tokens + (calls backend /api/auth/token/exchange) + โ†“ +Stores tokens in sessionStorage + โ†“ +Redirects to original destination (or /) + โ†“ +โœ… User is logged in! +``` + +### Security Features + +1. **PKCE Flow**: Code Challenge prevents authorization code interception +2. **State Parameter**: CSRF protection via random state token +3. **Session Storage**: Tokens stored in sessionStorage (cleared on tab close) +4. **Automatic Refresh**: Tokens auto-refresh 60 seconds before expiry + +--- + +## Testing the Login Flow + +### 1. Start Keycloak +```bash +docker-compose up -d keycloak +``` + +Wait for Keycloak to be ready (check logs): +```bash +docker-compose logs -f keycloak | grep "started" +``` + +### 2. Restart Backend +This triggers automatic redirect URI registration: +```bash +docker-compose restart backend +``` + +Check for successful registration: +```bash +docker-compose logs backend | grep KC-STARTUP +``` + +You should see: +``` +[KC-STARTUP] ๐Ÿ” Registering redirect URIs with Keycloak... +[KC-STARTUP] Environment: PORT_OFFSET=10 +[KC-STARTUP] โœ… Redirect URIs registered successfully +``` + +### 3. Test Login +1. Navigate to `http://localhost:3010/login` +2. Click "Sign in with Keycloak" +3. You'll be redirected to Keycloak at `http://localhost:8081` +4. Login with Keycloak credentials (default: admin / changeme) +5. You'll be redirected back to the app and logged in + +### 4. Verify Token Storage +Open browser DevTools โ†’ Application โ†’ Session Storage โ†’ `http://localhost:3010` + +You should see: +- `kc_access_token`: JWT access token +- `kc_refresh_token`: Refresh token +- `kc_id_token`: ID token with user info + +--- + +## Troubleshooting + +### Redirect URI Error +**Symptom**: "Invalid parameter: redirect_uri" when clicking login + +**Cause**: Keycloak client doesn't have the redirect URI whitelisted + +**Solution**: +1. Check if auto-registration succeeded: + ```bash + docker-compose logs backend | grep KC-STARTUP + ``` + +2. If it failed, manually register the URI: + - Go to Keycloak admin: `http://localhost:8081` + - Login with admin credentials + - Navigate to: Clients โ†’ ushadow-frontend โ†’ Settings + - Add to "Valid Redirect URIs": `http://localhost:3010/oauth/callback` + - Click "Save" + +### Still Redirects to Login +**Symptom**: After Keycloak login, you're sent back to the login page + +**Cause**: OAuth callback route not working + +**Check**: +1. Open browser DevTools โ†’ Console +2. Look for errors during callback processing +3. Check Network tab for failed API calls to `/api/auth/token/exchange` + +**Common Issues**: +- Backend not running +- Keycloak not reachable from backend +- CORS issues (check backend CORS configuration) + +### Token Exchange Fails +**Symptom**: Error message on callback page: "Authentication failed" + +**Check Backend Logs**: +```bash +docker-compose logs -f backend | grep -i keycloak +``` + +**Common Causes**: +- Keycloak client secret not configured +- Backend can't reach Keycloak at `http://keycloak:8080` +- PKCE verification failed (check code_verifier in sessionStorage) + +--- + +## Architecture Notes + +### Dual Authentication System + +The system now supports **both** authentication methods simultaneously: + +1. **Keycloak OAuth (Primary)** + - Modern SSO with federated identity + - Supports Google, GitHub, etc. (when configured in Keycloak) + - Used by default for new users + +2. **Legacy JWT (Fallback)** + - Email/password in ushadow database + - Backward compatible with existing users + - Used for admin access if Keycloak is down + +### Provider Hierarchy + +``` +App +โ”œโ”€ KeycloakAuthProvider (outer) +โ”‚ โ””โ”€ Provides: isAuthenticated, login, logout (OAuth) +โ”‚ +โ””โ”€ AuthProvider (inner) + โ””โ”€ Provides: user, token (legacy JWT) +``` + +LoginPage uses KeycloakAuthProvider exclusively. Protected routes can check either provider. + +--- + +## Next Steps + +### 1. Configure Keycloak Client Secret +For production, set a proper client secret: + +1. Generate a secret in Keycloak admin console +2. Update `config/SECRETS/secrets.yaml`: + ```yaml + keycloak: + backend_client_secret: 'your-generated-secret' + ``` + +### 2. Set Up User Federation +Configure Keycloak to sync with external identity providers: +- Google OAuth +- GitHub OAuth +- Corporate LDAP/AD + +### 3. Test Share Feature with Keycloak +Now that login works, test the complete share flow: + +1. Login with Keycloak +2. Navigate to a conversation +3. Click "Share" button +4. Create a share link +5. Verify the share URL uses your Tailscale hostname + +--- + +## Files Modified + +### Frontend +- โœ… `ushadow/frontend/src/pages/LoginPage.tsx` - Simplified to SSO button only +- โœ… `ushadow/frontend/src/App.tsx` - Added OAuth callback route +- โœ… `ushadow/frontend/package.json` - Added jwt-decode dependency + +### Backend Configuration +- โœ… `config/config.defaults.yaml` - Enabled Keycloak +- โœ… `config/SECRETS/secrets.yaml` - Added Keycloak credentials + +### Share Feature (from previous work) +- โœ… `ushadow/backend/src/routers/share.py` - Implemented share URL strategy +- โœ… `ushadow/frontend/src/pages/ConversationDetailPage.tsx` - Added share button +- โœ… Complete conversation sharing infrastructure + +--- + +## Summary + +**Before**: Login page had non-functional password fields, OAuth callback wasn't registered, and Keycloak was disabled. + +**After**: Clean SSO login flow with Keycloak, automatic redirect URI registration, and complete OAuth callback handling. + +**Impact**: Users can now successfully log in via Keycloak and access the full share feature with proper authentication! diff --git a/SHARE_FEATURE_SUMMARY.md b/SHARE_FEATURE_SUMMARY.md new file mode 100644 index 00000000..9e65b5c3 --- /dev/null +++ b/SHARE_FEATURE_SUMMARY.md @@ -0,0 +1,194 @@ +# Share Feature - Complete Implementation Summary + +## What Users Will See + +When clicking "Share" on a conversation, users will get URLs in this format: + +### Default (No Configuration) +If you have Tailscale configured: +``` +https://your-machine.tail12345.ts.net/share/a1b2c3d4-e5f6-7890-abcd-ef1234567890 +``` + +If Tailscale is not configured (development): +``` +http://localhost:3000/share/a1b2c3d4-e5f6-7890-abcd-ef1234567890 +``` + +### With Environment Variable Override +If you set `SHARE_BASE_URL` in `.env`: +```bash +SHARE_BASE_URL=https://ushadow.mycompany.com +``` +Users get: +``` +https://ushadow.mycompany.com/share/a1b2c3d4-e5f6-7890-abcd-ef1234567890 +``` + +### With Public Gateway +If you set `SHARE_PUBLIC_GATEWAY` in `.env`: +```bash +SHARE_PUBLIC_GATEWAY=https://share.yourdomain.com +``` +Users get: +``` +https://share.yourdomain.com/share/a1b2c3d4-e5f6-7890-abcd-ef1234567890 +``` + +--- + +## How Share Links Work + +1. **User clicks "Share" button** in conversation detail page +2. **ShareDialog opens** with options: + - Expiration date (optional) + - Max view count (optional) + - Require authentication (toggle) + - Tailscale-only access (toggle) +3. **Backend creates token** with ownership validation +4. **Frontend displays link** with copy-to-clipboard button +5. **Recipient clicks link** โ†’ Token validated โ†’ Conversation displayed + +--- + +## Complete Feature Set + +### โœ… Frontend Integration +- **ConversationsPage** (`/conversations`) - Multi-source list view (Chronicle + Mycelia) +- **ConversationDetailPage** (`/conversations/{id}?source={source}`) - Full conversation view with: + - Audio playback (full + segment-level) + - Memory integration + - Transcript display + - **Share button** (green button next to "Play Full Audio") +- **ShareDialog** - Full-featured modal with all share options +- **useShare hook** - State management for share dialog + +### โœ… Backend API +- `POST /api/share/create` - Create new share token +- `GET /api/share/{token}` - Access shared resource (public endpoint) +- `DELETE /api/share/{token}` - Revoke share token +- `GET /api/share/resource/{type}/{id}` - List shares for resource +- `GET /api/share/{token}/logs` - View access logs +- `POST /api/share/conversations/{id}` - Convenience endpoint for conversations + +### โœ… Security Features +- **Ownership validation** - Users can only share their own conversations +- **Superuser bypass** - Admins can share anything +- **Optional features** (environment variable gates): + - Resource validation (`SHARE_VALIDATE_RESOURCES`) + - Tailscale IP validation (`SHARE_VALIDATE_TAILSCALE`) +- **Access logging** - Audit trail of all share access +- **Expiration** - Time-based token expiry +- **View limits** - Maximum number of accesses + +### โœ… URL Configuration +- **Strategy hierarchy** (priority order): + 1. `SHARE_BASE_URL` environment variable + 2. `SHARE_PUBLIC_GATEWAY` environment variable + 3. Tailscale hostname (auto-detected) + 4. Localhost fallback (development) + +--- + +## Testing the Feature + +### 1. Start ushadow +```bash +# Check that backend logs show: +# "Share service initialized with base_url: https://..." +docker-compose up -d +docker-compose logs -f backend | grep "Share service" +``` + +### 2. Navigate to conversations +``` +http://localhost:3010/conversations +``` + +### 3. Click any conversation to view details +``` +http://localhost:3010/conversations/{id}?source=mycelia +``` + +### 4. Click "Share" button +- Creates share token +- Displays URL with your configured base URL +- Shows existing shares below + +### 5. Test the share link +- Copy the generated URL +- Open in incognito/private window +- Should show conversation details (if public) +- OR require Tailscale access (if `tailscale_only: true`) + +--- + +## Configuration Examples + +### Scenario 1: Development (No Config Needed) +```bash +# No environment variables set +# URLs will be: http://localhost:3000/share/{token} +``` + +### Scenario 2: Tailscale Deployment +```bash +# Tailscale auto-detected from tailscale-config.json +# URLs will be: https://ushadow.tail12345.ts.net/share/{token} +``` + +### Scenario 3: Custom Domain +```bash +# In .env: +SHARE_BASE_URL=https://ushadow.mycompany.com + +# URLs will be: https://ushadow.mycompany.com/share/{token} +``` + +### Scenario 4: Public Gateway +```bash +# In .env: +SHARE_PUBLIC_GATEWAY=https://share.yourdomain.com + +# URLs will be: https://share.yourdomain.com/share/{token} +# Requires deploying share-gateway/ to public VPS +``` + +--- + +## Next Steps (Optional) + +### Implement Resource Fetching +Currently the share access endpoint returns placeholder data. To show actual conversation content, implement resource fetching in: +- `ushadow/backend/src/routers/share.py` line 136 +- Call Mycelia API to fetch conversation data +- Filter sensitive fields before returning + +### Deploy Share Gateway (For External Sharing) +If you want external friends to access shares: +1. Deploy `share-gateway/` to public VPS +2. Set `SHARE_PUBLIC_GATEWAY` environment variable +3. Configure gateway to proxy back through Tailscale + +### Enable Tailscale Funnel (Alternative to Gateway) +If you want external access without deploying a gateway: +```bash +tailscale funnel --bg --https=443 --set-path=/share https+insecure://localhost:8010 +``` + +--- + +## Architecture Decision: Why This Approach? + +โ˜… **Flexible URL Configuration** +The hierarchy allows you to start simple (Tailscale auto-detection) and upgrade later (public gateway) without changing code. Just set an environment variable. + +โ˜… **Security by Default** +Ownership validation ensures users can only share their own content. Superuser bypass provides admin flexibility for support/moderation. + +โ˜… **Progressive Enhancement** +- Basic: Tailnet-only sharing (zero config) +- Intermediate: Funnel for selective public access +- Advanced: Full public gateway with rate limiting + +This matches your "behind Tailscale" deployment while keeping external sharing as an option when you're ready. diff --git a/SHARE_URL_CONFIGURATION.md b/SHARE_URL_CONFIGURATION.md new file mode 100644 index 00000000..e06ce70d --- /dev/null +++ b/SHARE_URL_CONFIGURATION.md @@ -0,0 +1,246 @@ +# Share URL Configuration for Tailscale Deployments + +## The Challenge + +When running ushadow behind Tailscale, you face a fundamental question: **Who should be able to access shared links?** + +Your share links will look like: +``` +https://YOUR_BASE_URL/share/a1b2c3d4-e5f6-7890-abcd-ef1234567890 +``` + +But what should `YOUR_BASE_URL` be? + +--- + +## Three Sharing Strategies + +### Strategy 1: Tailnet-Only Sharing (Simplest) + +**Best for:** Sharing with colleagues/friends who are already on your Tailnet + +**Setup:** +```bash +# In your .env file +SHARE_BASE_URL=https://ushadow.tail12345.ts.net +``` + +**How it works:** +1. User clicks "Share" in conversation detail page +2. Gets link like: `https://ushadow.tail12345.ts.net/share/{token}` +3. Only people connected to your Tailnet can access + +**Implementation:** +```python +# In ushadow/backend/src/routers/share.py, implement _get_share_base_url(): +def _get_share_base_url() -> str: + # Try explicit override first + if base_url := os.getenv("SHARE_BASE_URL"): + return base_url.rstrip("/") + + # Use Tailscale hostname + try: + config = read_tailscale_config() + if config and config.hostname: + return f"https://{config.hostname}" + except Exception: + pass + + # Fallback + return "http://localhost:3000" +``` + +**Pros:** +- โœ… Simple - no extra infrastructure +- โœ… Secure - protected by Tailscale ACLs +- โœ… Works immediately + +**Cons:** +- โŒ Recipients must join your Tailnet +- โŒ Not suitable for external friends + +--- + +### Strategy 2: Tailscale Funnel (Public Access via Tailscale) + +**Best for:** Sharing with external friends without deploying separate infrastructure + +**Setup:** +```bash +# Enable Funnel for specific paths +tailscale funnel --bg --https=443 --set-path=/share https+insecure://localhost:8010 + +# In your .env file +SHARE_BASE_URL=https://ushadow.tail12345.ts.net +``` + +**How it works:** +1. Tailscale Funnel exposes `/share/*` endpoints publicly through Tailscale's infrastructure +2. Share links use your Tailscale hostname +3. External users access via public internet โ†’ Tailscale Funnel โ†’ Your ushadow instance + +**Implementation:** Same as Strategy 1 (Funnel is transparent to your app) + +**Pros:** +- โœ… No separate VPS needed +- โœ… Tailscale handles SSL certificates +- โœ… Can selectively expose endpoints + +**Cons:** +- โŒ Requires Tailscale Funnel configuration +- โŒ Funnel has bandwidth limits +- โŒ May not work with all Tailscale plans + +--- + +### Strategy 3: Public Gateway (Maximum Flexibility) + +**Best for:** Production deployments with external sharing and fine-grained control + +**Setup:** +1. Deploy `share-gateway/` to a public VPS (e.g., DigitalOcean) +2. Configure gateway to proxy back to your Tailscale network +3. Set environment variable: + +```bash +# In your .env file +SHARE_PUBLIC_GATEWAY=https://share.yourdomain.com +``` + +**How it works:** +1. User clicks "Share" in conversation +2. Gets link like: `https://share.yourdomain.com/share/{token}` +3. Gateway validates token with your ushadow backend via Tailscale +4. Gateway proxies the conversation data back to external user + +**Implementation:** +```python +def _get_share_base_url() -> str: + # Public gateway for external sharing (highest priority) + if gateway_url := os.getenv("SHARE_PUBLIC_GATEWAY"): + return gateway_url.rstrip("/") + + # Explicit override + if base_url := os.getenv("SHARE_BASE_URL"): + return base_url.rstrip("/") + + # Fallback to Tailscale hostname + try: + config = read_tailscale_config() + if config and config.hostname: + return f"https://{config.hostname}" + except Exception: + pass + + return "http://localhost:3000" +``` + +**Gateway Deployment:** +```bash +cd share-gateway/ +docker build -t ushadow-share-gateway . +docker run -d -p 443:8000 \ + -e USHADOW_BACKEND_URL=https://ushadow.tail12345.ts.net \ + -e RATE_LIMIT_PER_IP=10 \ + ushadow-share-gateway +``` + +**Pros:** +- โœ… Full control over public endpoint +- โœ… Custom domain and SSL +- โœ… Rate limiting and security controls +- โœ… No bandwidth limits + +**Cons:** +- โŒ Requires deploying separate service +- โŒ Monthly VPS cost (~$5-10/month) +- โŒ More complex architecture + +--- + +## Recommended Implementation + +Here's the complete implementation for `_get_share_base_url()` in `ushadow/backend/src/routers/share.py`: + +```python +def _get_share_base_url() -> str: + """Determine the base URL for share links. + + Strategy hierarchy: + 1. SHARE_BASE_URL environment variable (highest priority) + 2. SHARE_PUBLIC_GATEWAY environment variable (for external sharing) + 3. Tailscale hostname (for Tailnet-only sharing) + 4. Fallback to localhost (development only) + + Returns: + Base URL string (e.g., "https://ushadow.tail12345.ts.net") + """ + # Explicit override (for testing or custom deployments) + if base_url := os.getenv("SHARE_BASE_URL"): + logger.info(f"Using explicit SHARE_BASE_URL: {base_url}") + return base_url.rstrip("/") + + # Public gateway for external sharing + if gateway_url := os.getenv("SHARE_PUBLIC_GATEWAY"): + logger.info(f"Using public gateway: {gateway_url}") + return gateway_url.rstrip("/") + + # Use Tailscale hostname (works with or without Funnel) + try: + config = read_tailscale_config() + if config and config.hostname: + tailscale_url = f"https://{config.hostname}" + logger.info(f"Using Tailscale hostname: {tailscale_url}") + return tailscale_url + except Exception as e: + logger.warning(f"Failed to read Tailscale config: {e}") + + # Fallback for development + logger.warning("Using localhost fallback - shares will only work locally!") + return "http://localhost:3000" +``` + +--- + +## Quick Start + +**For immediate Tailnet-only sharing:** +```bash +# No configuration needed! Just use the Tailscale hostname detection +# Share links will automatically use: https://ushadow.tail{xxx}.ts.net +``` + +**To override:** +```bash +# Add to your .env file +SHARE_BASE_URL=https://your-custom-url.com +``` + +--- + +## Testing Your Configuration + +1. Start ushadow backend +2. Check logs for: `Share service initialized with base_url: ...` +3. Create a share link from conversation detail page +4. Verify the URL format matches your expected base URL + +--- + +## Security Considerations + +### Tailnet-Only Sharing +- Protected by Tailscale ACLs +- No public exposure +- Requires recipients to join Tailnet + +### Funnel Sharing +- Only `/share/*` endpoints exposed +- Still uses Tailscale authentication for admin features +- Funnel has rate limiting built-in + +### Public Gateway Sharing +- Gateway validates all tokens before proxying +- Rate limiting per IP (default: 10 requests/minute) +- Admin endpoints still require Tailscale access +- Consider adding additional authentication for sensitive shares diff --git a/SHARING_IMPLEMENTATION.md b/SHARING_IMPLEMENTATION.md new file mode 100644 index 00000000..f634882d --- /dev/null +++ b/SHARING_IMPLEMENTATION.md @@ -0,0 +1,739 @@ +# Ushadow Sharing System - Implementation Guide + +## Overview + +This document describes the conversation sharing system I've implemented for Ushadow, designed to integrate with Keycloak Fine-Grained Authorization (FGA) while remaining functional with the current JWT authentication system. + +## ๐ŸŒ Architecture: Behind Tailscale + Public Sharing + +Since ushadow runs **behind your private Tailscale network**, external users cannot directly access it. The sharing system supports **two modes**: + +### Mode 1: Tailscale-Only Sharing +- User sets `tailscale_only=true` on share link +- Friend must join your Tailnet (temporarily or permanently) +- Friend accesses ushadow directly via Tailscale +- Most secure, zero trust + +### Mode 2: Public Share Gateway (Recommended) +- User sets `tailscale_only=false` on share link +- Share link points to public gateway: `https://share.yourdomain.com/c/{token}` +- Gateway validates token, proxies ONLY shared resource +- Gateway connects to ushadow via Tailscale (private connection) +- Friend never has direct access to your Tailnet + +**Gateway Architecture**: +``` +Public Internet +โ”‚ +โ”œโ”€โ”€ Friend visits: https://share.yourdomain.com/c/550e8400-... +โ”‚ +โ–ผ +Share Gateway (Public VPS, ~$5/month) +โ”‚ - Validates share token +โ”‚ - Rate limited (10 req/min per IP) +โ”‚ - Audit logging +โ”‚ - Only exposes /c/{token} endpoint +โ”‚ +โ–ผ (via Tailscale) +Your Private Tailnet +โ”œโ”€โ”€ ushadow backend โ† Friend NEVER accesses directly +โ”œโ”€โ”€ MongoDB +โ””โ”€โ”€ Your devices +``` + +**Gateway Implementation**: See `share-gateway/` directory for complete deployment-ready code. + +## What's Been Built + +### โœ… Backend (Complete) + +**Models** (`ushadow/backend/src/models/share.py`): +- `ShareToken` - Beanie document for MongoDB storage +- `ShareTokenCreate` - API request model +- `ShareTokenResponse` - API response model +- `KeycloakPolicy` - Keycloak-compatible policy structure +- Enums: `ResourceType`, `SharePermission` + +**Service** (`ushadow/backend/src/services/share_service.py`): +- `ShareService` - Business logic for share management +- Token creation/validation/revocation +- Audit logging for all access +- Keycloak integration stubs (ready for implementation) + +**API Router** (`ushadow/backend/src/routers/share.py`): +- `POST /api/share/create` - Create share token +- `GET /api/share/{token}` - Access shared resource +- `DELETE /api/share/{token}` - Revoke share +- `GET /api/share/resource/{type}/{id}` - List shares for resource +- `GET /api/share/{token}/logs` - View access audit logs +- Convenience endpoints: `/api/share/conversations/{id}` + +### โœ… Frontend (Complete) + +**Components** (`ushadow/frontend/src/components/`): +- `ShareDialog.tsx` - Full-featured share management UI + - Create share links with expiration/view limits + - List existing shares + - Copy links to clipboard + - Revoke access with confirmation + +**Hooks** (`ushadow/frontend/src/hooks/`): +- `useShare.ts` - Share dialog state management + +### ๐Ÿ“‹ Configuration + +**Database**: ShareToken collection added to Beanie initialization in `main.py`: +```python +await init_beanie(database=db, document_models=[User, ShareToken]) +``` + +**Router**: Share router registered in `main.py`: +```python +app.include_router(share.router, tags=["sharing"]) +``` + +--- + +## ๐ŸŽฏ Key Decision Points (TODO for You) + +I've intentionally left several business logic decisions for you to implement. These are marked with `TODO` comments in the code and represent strategic choices that should align with your security and UX requirements. + +### 1. Resource Validation (`share_service.py:260-273`) + +**Location**: `ShareService._validate_resource_exists()` + +**Current State**: Placeholder that skips validation + +**Decision Point**: How should we verify that a conversation/memory/resource exists before creating a share link? + +```python +async def _validate_resource_exists( + self, + resource_type: ResourceType, + resource_id: str, +): + """Validate that resource exists and is accessible. + + TODO: Implement resource validation + - For conversations: Check Chronicle API + - For memories: Check Mycelia API + - Raise ValueError if resource doesn't exist + """ +``` + +**Options**: +1. **Strict**: Call Chronicle/Mycelia API to verify resource exists +2. **Lazy**: Assume resource exists, fail when accessed +3. **Cache-based**: Check local cache/database first + +**Trade-offs**: +- Strict validation prevents sharing non-existent resources but adds API latency +- Lazy validation is faster but could create broken share links +- Cache-based is fast but might be stale + +**Recommended Implementation**: +```python +# Example for conversations +if resource_type == ResourceType.CONVERSATION: + response = await httpx.get( + f"{CHRONICLE_URL}/conversations/{resource_id}", + headers={"Authorization": f"Bearer {token}"} + ) + if response.status_code == 404: + raise ValueError(f"Conversation {resource_id} not found") +``` + +--- + +### 2. Authorization Check (`share_service.py:275-291`) + +**Location**: `ShareService._validate_user_can_share()` + +**Current State**: Allows all authenticated users + +**Decision Point**: Who should be allowed to share a resource? + +```python +async def _validate_user_can_share( + self, + user: User, + resource_type: ResourceType, + resource_id: str, +): + """Validate user has permission to share resource. + + TODO: DECISION POINT - Implement authorization check + Options: + 1. Strict: Only resource owner can share + 2. Permissive: Anyone with read access can share + 3. Role-based: Only users with "share" permission can share + """ +``` + +**Options**: +1. **Owner-only**: Only the user who created the resource can share it +2. **Viewer-based**: Anyone who can view the resource can share it +3. **Role-based**: Check Keycloak roles/permissions +4. **Admin-only**: Only superusers can create shares + +**Trade-offs**: +- Owner-only is most secure but limits collaboration +- Viewer-based enables viral sharing but may leak sensitive data +- Role-based requires Keycloak integration +- Admin-only prevents user-driven sharing + +**Recommended Implementation**: +```python +# Option 1: Owner-only (strictest) +conversation = await get_conversation(resource_id) +if str(conversation.user_id) != str(user.id) and not user.is_superuser: + raise ValueError("Only the conversation owner can create share links") + +# Option 2: Viewer-based (most permissive) +# If user can fetch the resource, they can share it +# (validation happens in _validate_resource_exists) + +# Option 3: Role-based (Keycloak) +if not await keycloak.has_permission(user.id, resource_id, "share"): + raise ValueError("User lacks share permission for this resource") +``` + +--- + +### 3. Tailscale Network Validation (`share_service.py:293-308`) + +**Location**: `ShareService._validate_tailscale_access()` + +**Current State**: Always returns True (allows all) + +**Decision Point**: How should we verify requests are from your Tailscale network? + +```python +async def _validate_tailscale_access(self, request_ip: Optional[str]) -> bool: + """Validate request is from Tailscale network. + + TODO: DECISION POINT - Implement Tailscale validation + Options: + 1. Check IP ranges (Tailscale CGNAT 100.64.0.0/10) + 2. Validate via Tailscale API + 3. Trust X-Forwarded-For from Tailscale reverse proxy + """ +``` + +**Options**: +1. **IP Range Check**: Verify IP is in Tailscale CGNAT range (100.64.0.0/10) +2. **Tailscale API**: Call Tailscale API to verify device membership +3. **Reverse Proxy Headers**: Trust `X-Tailscale-User` header from Tailscale Serve +4. **Mutual TLS**: Validate client certificates + +**Trade-offs**: +- IP range check is fast but can be spoofed if not behind Tailscale +- API validation is authoritative but adds latency +- Header trust is fast but requires secure reverse proxy setup +- mTLS is most secure but complex to set up + +**Recommended Implementation**: +```python +# Option 1: IP Range Check (simple, fast) +import ipaddress + +if not request_ip: + return False + +ip = ipaddress.ip_address(request_ip) +tailscale_range = ipaddress.ip_network("100.64.0.0/10") +return ip in tailscale_range + +# Option 3: Header Trust (requires Tailscale Serve) +def get_tailscale_user(request: Request) -> Optional[str]: + return request.headers.get("X-Tailscale-User") + +if share_token.tailscale_only and not get_tailscale_user(request): + return False, "Access restricted to Tailscale network" +``` + +--- + +### 4. Keycloak FGA Integration (`share_service.py:310-330`) + +**Location**: `ShareService._register_with_keycloak()` and `_unregister_from_keycloak()` + +**Current State**: Stub methods with debug logging + +**Decision Point**: How should share tokens integrate with Keycloak Fine-Grained Authorization? + +```python +async def _register_with_keycloak(self, share_token: ShareToken): + """Register share token with Keycloak FGA. + + TODO: Implement Keycloak FGA registration + This should: + 1. Create Keycloak resource for the shared item + 2. Create Keycloak authorization policies + 3. Store keycloak_policy_id and keycloak_resource_id on share_token + """ +``` + +**Implementation Steps**: +1. Create Keycloak resource: + ```python + resource = await keycloak.create_resource( + name=f"{share_token.resource_type}:{share_token.resource_id}", + type=share_token.resource_type, + owner=str(share_token.created_by) + ) + share_token.keycloak_resource_id = resource["_id"] + ``` + +2. Create authorization policies: + ```python + for policy in share_token.policies: + kc_policy = await keycloak.create_policy( + name=f"share-{share_token.token}", + resources=[resource["_id"]], + scopes=[policy.action], + logic="POSITIVE", + decision_strategy="UNANIMOUS" + ) + share_token.keycloak_policy_id = kc_policy["id"] + ``` + +3. Grant permissions to anonymous users (if `require_auth=False`): + ```python + if not share_token.require_auth: + await keycloak.create_permission( + name=f"anon-access-{share_token.token}", + policy=kc_policy["id"], + resources=[resource["_id"]], + decision_strategy="AFFIRMATIVE" + ) + ``` + +**Libraries to Consider**: +- `python-keycloak` - Official Python client +- `httpx` - Direct REST API calls to Keycloak + +--- + +### 5. Base URL Configuration (`share.py:32` and `share_service.py:26`) + +**Location**: `get_share_service()` in `share.py` + +**Current State**: Hardcoded to `http://localhost:3000` + +**Decision Point**: How should the frontend URL be configured? + +```python +def get_share_service(db: AsyncIOMotorDatabase = Depends(get_database)) -> ShareService: + # TODO: Get base_url from settings + base_url = "http://localhost:3000" + return ShareService(db=db, base_url=base_url) +``` + +**Options**: +1. **Environment Variable**: `FRONTEND_URL` in `.env` +2. **Settings File**: Add to `config/config.defaults.yaml` +3. **Auto-detect**: Use request.base_url from FastAPI +4. **Per-environment**: Different URLs for dev/prod + +**Recommended Implementation**: +```python +from src.config.omegaconf_settings import get_settings + +async def get_share_service( + db: AsyncIOMotorDatabase = Depends(get_database) +) -> ShareService: + settings = get_settings() + base_url = await settings.get( + "network.frontend_url", + default="http://localhost:3000" + ) + return ShareService(db=db, base_url=base_url) +``` + +--- + +## ๐Ÿ“š Usage Examples + +### Creating a Share Link (Frontend) + +```tsx +import ShareDialog from '@/components/ShareDialog' +import { useShare } from '@/hooks/useShare' +import { Share2 } from 'lucide-react' + +function ConversationView({ conversationId }: { conversationId: string }) { + const shareProps = useShare({ + resourceType: 'conversation', + resourceId: conversationId + }) + + return ( +
+ + + +
+ ) +} +``` + +### Accessing a Shared Resource (API) + +```bash +# Public access (no auth required) +curl https://ushadow.example.com/api/share/550e8400-e29b-41d4-a716-446655440000 + +# Response +{ + "share_token": { + "token": "550e8400-e29b-41d4-a716-446655440000", + "share_url": "https://ushadow.example.com/share/550e8400-...", + "permissions": ["read"], + "expires_at": "2026-02-08T14:35:00Z", + "view_count": 1 + }, + "resource": { + "type": "conversation", + "id": "conv_123", + "data": "Placeholder for conversation:conv_123" + } +} +``` + +### Revoking a Share Link + +```typescript +// From ShareDialog component +const revokeShareMutation = useMutation({ + mutationFn: async (token: string) => { + const response = await fetch(`/api/share/${token}`, { + method: 'DELETE', + credentials: 'include', + }) + if (!response.ok) throw new Error('Failed to revoke') + } +}) + +await revokeShareMutation.mutateAsync(shareToken) +``` + +--- + +## ๐Ÿ” Security Features + +### Built-in Protections + +1. **Expiration**: Tokens can have TTL (expires_at) +2. **View Limits**: Tokens can have max_views +3. **Authentication**: `require_auth` flag enforces login +4. **Network Restriction**: `tailscale_only` limits to your private network +5. **Email Allowlist**: `allowed_emails` restricts to specific users +6. **Audit Logging**: Every access is logged with timestamp, user/IP, and metadata + +### Audit Trail Example + +```json +{ + "timestamp": "2026-02-01T15:30:00Z", + "user_identifier": "friend@example.com", + "action": "view", + "view_count": 3, + "metadata": { + "ip": "100.64.0.5", + "user_agent": "Mozilla/5.0..." + } +} +``` + +--- + +## ๐Ÿงช Testing + +### Manual Testing Checklist + +- [ ] Create share link with expiration +- [ ] Create share link with view limit +- [ ] Create Tailscale-only share +- [ ] Create auth-required share +- [ ] Copy share link to clipboard +- [ ] Access share link (anonymous) +- [ ] Access share link (authenticated) +- [ ] Revoke share link +- [ ] View audit logs +- [ ] Share link expires correctly +- [ ] View limit enforced + +### API Testing + +```bash +# 1. Create share token +curl -X POST http://localhost:8080/api/share/create \ + -H "Content-Type: application/json" \ + -H "Cookie: ushadow_auth=YOUR_TOKEN" \ + -d '{ + "resource_type": "conversation", + "resource_id": "test_conv_123", + "permissions": ["read"], + "expires_in_days": 7, + "require_auth": false, + "tailscale_only": false + }' + +# 2. Access share token (public) +curl http://localhost:8080/api/share/SHARE_TOKEN_UUID + +# 3. List shares for resource +curl http://localhost:8080/api/share/resource/conversation/test_conv_123 \ + -H "Cookie: ushadow_auth=YOUR_TOKEN" + +# 4. Revoke share +curl -X DELETE http://localhost:8080/api/share/SHARE_TOKEN_UUID \ + -H "Cookie: ushadow_auth=YOUR_TOKEN" +``` + +--- + +## ๐Ÿ“‹ Next Steps + +1. **Implement Decision Points** (above) + - Resource validation + - Authorization checks + - Tailscale validation + - Keycloak integration + - Base URL configuration + +2. **Update Chronicle Integration** + - Modify conversation routes to support share token access + - See section below for guidance + +3. **Frontend Integration** + - Add share button to Chronicle conversation UI + - Import and use ShareDialog component + +4. **Production Configuration** + - Set `FRONTEND_URL` environment variable + - Configure Keycloak if using FGA + - Set up Tailscale Serve if using network restriction + +--- + +## ๐Ÿ”ง Chronicle Integration Guide + +To allow shared conversations to be accessed via share tokens, you'll need to modify the Chronicle conversation routes. + +**File**: `chronicle/backends/advanced/src/advanced_omi_backend/routers/modules/conversation_routes.py` + +**Current State**: +```python +@router.get("/conversations/{conversation_id}") +async def get_conversation( + conversation_id: str, + current_user: User = Depends(current_active_user) +): + # Check ownership + if not current_user.is_superuser and conversation.user_id != str(current_user.id): + raise HTTPException(403) +``` + +**Required Changes**: + +1. Add optional share token parameter: +```python +from typing import Optional, Union +from fastapi import Query + +@router.get("/conversations/{conversation_id}") +async def get_conversation( + conversation_id: str, + share_token: Optional[str] = Query(None), # Add this + current_user: Optional[User] = Depends(get_optional_current_user), # Make optional +): +``` + +2. Add share token validation: +```python +# If share token provided, validate it +if share_token: + share_service = ShareService(db=db, base_url=BASE_URL) + is_valid, token_obj, reason = await share_service.validate_share_access( + token=share_token, + user_email=current_user.email if current_user else None, + request_ip=request.client.host if request.client else None + ) + + if not is_valid: + raise HTTPException(403, detail=reason) + + # Verify token is for this conversation + if token_obj.resource_id != conversation_id: + raise HTTPException(403, detail="Share token not valid for this conversation") + + # Record access + user_identifier = current_user.email if current_user else request.client.host + await share_service.record_share_access( + share_token=token_obj, + user_identifier=user_identifier, + action="view", + metadata={"user_agent": request.headers.get("user-agent")} + ) + + # Skip ownership check - share token grants access +else: + # Original ownership check + if not current_user: + raise HTTPException(401, detail="Authentication required") + + if not current_user.is_superuser and conversation.user_id != str(current_user.id): + raise HTTPException(403, detail="Access denied") +``` + +--- + +## ๐Ÿ“Š Database Schema + +### ShareToken Collection + +```python +{ + "_id": ObjectId("..."), + "token": "550e8400-e29b-41d4-a716-446655440000", # UUID, indexed + "resource_type": "conversation", # Indexed + "resource_id": "conv_123", # Indexed + "created_by": ObjectId("..."), # User who created + "policies": [ + { + "resource": "conversation:conv_123", + "action": "read", + "effect": "allow" + } + ], + "permissions": ["read"], + "require_auth": false, + "tailscale_only": false, + "allowed_emails": [], + "expires_at": ISODate("2026-02-08T14:35:00Z"), + "max_views": null, + "view_count": 5, + "last_accessed_at": ISODate("2026-02-01T15:30:00Z"), + "last_accessed_by": "friend@example.com", + "access_log": [ + { + "timestamp": ISODate("2026-02-01T15:30:00Z"), + "user_identifier": "friend@example.com", + "action": "view", + "view_count": 5, + "metadata": { + "ip": "100.64.0.5", + "user_agent": "Mozilla/5.0..." + } + } + ], + "keycloak_policy_id": null, + "keycloak_resource_id": null, + "created_at": ISODate("2026-02-01T14:35:00Z"), + "updated_at": ISODate("2026-02-01T15:30:00Z") +} +``` + +### Indexes + +- `token` (unique) +- `resource_type` +- `resource_id` +- `created_by` +- `expires_at` +- Compound: `(resource_type, resource_id)` + +--- + +## ๐ŸŽ“ Architecture Decisions + +### Why Keycloak-Compatible from Day One? + +The share token system uses `KeycloakPolicy` structures even though Keycloak isn't integrated yet because: + +1. **Future-proof**: When Keycloak FGA is added, migration is trivial +2. **Standards-based**: Follows OAuth2/UMA patterns +3. **Mycelia-compatible**: Matches existing policy structure in Mycelia +4. **Flexible**: Supports both simple permissions and complex policies + +### Why Separate from User Authentication? + +Share tokens are independent of the user auth system because: + +1. **Anonymous sharing**: Users without accounts can access shares +2. **Revocation**: Revoking a share doesn't affect user permissions +3. **Audit trail**: Clear separation between user actions and share access +4. **Expiration**: Shares can expire independently of user sessions + +--- + +## ๐Ÿ› Troubleshooting + +### "Database not initialized" Error + +**Cause**: FastAPI app.state.db not set + +**Fix**: Ensure `main.py` lifespan sets `app.state.db = db` + +### Share Links Not Working + +**Cause**: Router not registered + +**Fix**: Verify `app.include_router(share.router)` in `main.py` + +### "Share token not found" + +**Cause**: Token not in database or expired + +**Debug**: +```python +# In MongoDB shell +db.share_tokens.find({ token: "YOUR_TOKEN_UUID" }) + +# Check expiration +db.share_tokens.find({ + token: "YOUR_TOKEN_UUID", + expires_at: { $gt: new Date() } +}) +``` + +### Frontend Can't Fetch Shares + +**Cause**: CORS or auth cookies + +**Fix**: Check middleware setup in `main.py`: +```python +# CORS must allow credentials +app.add_middleware( + CORSMiddleware, + allow_credentials=True, + allow_origins=["http://localhost:3000"], +) +``` + +--- + +## ๐Ÿ“ Summary + +You now have a complete sharing system with: +- โœ… Backend models, service, and API +- โœ… Frontend UI and hooks +- โœ… Audit logging +- โœ… Keycloak-ready architecture +- ๐Ÿ“‹ Clear decision points for customization + +The system is ready to use once you implement the 5 decision points marked with TODO comments. Start with resource validation and authorization, then add Tailscale/Keycloak integration as needed for your security requirements. diff --git a/config/config.defaults.yaml b/config/config.defaults.yaml index 7cba8e18..4a03b3a6 100644 --- a/config/config.defaults.yaml +++ b/config/config.defaults.yaml @@ -17,6 +17,16 @@ auth: admin_email: admin@example.com admin_name: admin +# Keycloak OAuth Configuration +keycloak: + enabled: true + url: http://keycloak:8080 # Internal Docker URL + public_url: http://localhost:8081 # External browser URL + realm: ushadow + backend_client_id: ushadow-backend + frontend_client_id: ushadow-frontend + admin_user: admin + # Speech Detection Settings speech_detection: min_words: 5 diff --git a/config/config.yml b/config/config.yml index a5c9eb17..41545c5a 100644 --- a/config/config.yml +++ b/config/config.yml @@ -4,186 +4,7 @@ defaults: stt: stt-deepgram tts: tts-http vector_store: vs-qdrant -models: -- name: emberfang-llm - description: Emberfang One LLM - model_type: llm - model_provider: openai - model_name: gpt-oss-20b-f16 - model_url: http://192.168.1.166:8084/v1 - api_key: '1234' - model_params: - temperature: 0.2 - max_tokens: 2000 - model_output: json -- name: emberfang-embed - description: Emberfang embeddings (nomic-embed-text) - model_type: embedding - model_provider: openai - model_name: nomic-embed-text-v1.5 - model_url: http://192.168.1.166:8084/v1 - api_key: '1234' - embedding_dimensions: 768 - model_output: vector -- name: local-llm - description: Local Ollama LLM - model_type: llm - model_provider: ollama - api_family: openai - model_name: llama3.1:latest - model_url: http://localhost:11434/v1 - api_key: ${OPENAI_API_KEY:-ollama} - model_params: - temperature: 0.2 - max_tokens: 2000 - model_output: json -- name: local-embed - description: Local embeddings via Ollama nomic-embed-text - model_type: embedding - model_provider: ollama - api_family: openai - model_name: nomic-embed-text:latest - model_url: http://localhost:11434/v1 - api_key: ${OPENAI_API_KEY:-ollama} - embedding_dimensions: 768 - model_output: vector -- name: openai-llm - description: OpenAI GPT-4o-mini - model_type: llm - model_provider: openai - api_family: openai - model_name: gpt-4o-mini - model_url: https://api.openai.com/v1 - api_key: ${OPENAI_API_KEY:-} - model_params: - temperature: 0.2 - max_tokens: 2000 - model_output: json -- name: openai-embed - description: OpenAI text-embedding-3-small - model_type: embedding - model_provider: openai - api_family: openai - model_name: text-embedding-3-small - model_url: https://api.openai.com/v1 - api_key: ${OPENAI_API_KEY:-} - embedding_dimensions: 1536 - model_output: vector -- name: groq-llm - description: Groq LLM via OpenAI-compatible API - model_type: llm - model_provider: groq - api_family: openai - model_name: llama-3.1-70b-versatile - model_url: https://api.groq.com/openai/v1 - api_key: ${GROQ_API_KEY:-} - model_params: - temperature: 0.2 - max_tokens: 2000 - model_output: json -- name: vs-qdrant - description: Qdrant vector database - model_type: vector_store - model_provider: qdrant - api_family: qdrant - model_url: http://${QDRANT_BASE_URL:-qdrant}:${QDRANT_PORT:-6333} - model_params: - host: ${QDRANT_BASE_URL:-qdrant} - port: ${QDRANT_PORT:-6333} - collection_name: omi_memories -- name: stt-parakeet-batch - description: Parakeet NeMo ASR (batch) - model_type: stt - model_provider: parakeet - api_family: http - model_url: http://172.17.0.1:8767 - api_key: '' - operations: - stt_transcribe: - method: POST - path: /transcribe - content_type: multipart/form-data - response: - type: json - extract: - text: text - words: words - segments: segments -- name: stt-deepgram - description: Deepgram Nova 3 (batch) - model_type: stt - model_provider: deepgram - api_family: http - model_url: https://api.deepgram.com/v1 - api_key: ${DEEPGRAM_API_KEY:-} - operations: - stt_transcribe: - method: POST - path: /listen - headers: - Authorization: Token ${DEEPGRAM_API_KEY:-} - Content-Type: audio/raw - query: - model: nova-3 - language: multi - smart_format: 'true' - punctuate: 'true' - diarize: false - encoding: linear16 - sample_rate: 16000 - channels: '1' - response: - type: json - extract: - text: results.channels[0].alternatives[0].transcript - words: results.channels[0].alternatives[0].words - segments: results.channels[0].alternatives[0].paragraphs.paragraphs -- name: tts-http - description: Generic JSON TTS endpoint - model_type: tts - model_provider: custom - api_family: http - model_url: http://localhost:9000 - operations: - tts_synthesize: - method: POST - path: /synthesize - headers: - Content-Type: application/json - response: - type: json -- name: stt-parakeet-stream - description: Parakeet streaming transcription over WebSocket - model_type: stt_stream - model_provider: parakeet - api_family: websocket - model_url: ws://localhost:9001/stream - operations: - start: - message: - type: transcribe - config: - vad_enabled: true - vad_silence_ms: 1000 - time_interval_seconds: 30 - return_interim_results: true - min_audio_seconds: 0.5 - chunk_header: - message: - type: audio_chunk - rate: 16000 - width: 2 - channels: 1 - end: - message: - type: stop - expect: - interim_type: interim_result - final_type: final_result - extract: - text: text - words: words - segments: segments + memory: provider: chronicle timeout_seconds: 1200 diff --git a/config/feature_flags.yaml b/config/feature_flags.yaml index ee24ee3f..26ffd11d 100644 --- a/config/feature_flags.yaml +++ b/config/feature_flags.yaml @@ -76,7 +76,8 @@ flags: # Timeline - visualize memories on an interactive timeline timeline: enabled: true - description: "Timeline - Visualize memories with time ranges on Gantt charts and D3 timelines" + description: "Timeline - Visualize memories with time ranges on Gantt charts and + D3 timelines" type: release # ServiceConfigs Management - Service instance deployment and wiring @@ -89,13 +90,15 @@ flags: # Service Configs - Show custom service instance configurations service_configs: enabled: false - description: "Show custom service config instances in the Services tab (multi-instance per template)" + description: "Show custom service config instances in the Services tab (multi-instance + per template)" type: release # Split Services View - Organize services into API/Workers and UI tabs split_services: - enabled: false - description: "Split services into API & Workers and UI Services tabs with automatic worker grouping" + enabled: true + description: "Split services into API & Workers and UI Services tabs with automatic + worker grouping" type: release # Add your feature flags here following this format: diff --git a/config/service_configs.yaml b/config/service_configs.yaml new file mode 100644 index 00000000..d126b6b8 --- /dev/null +++ b/config/service_configs.yaml @@ -0,0 +1,7 @@ +instances: + chronicle-backend-ushadow--leader-: + template_id: chronicle-backend + name: chronicle-backend (ushadow (Leader)) + description: Docker deployment to ushadow (Leader) + created_at: '2026-02-03T00:39:13.236265+00:00' + updated_at: '2026-02-03T00:39:13.236265+00:00' diff --git a/config/wiring.yaml b/config/wiring.yaml index 3a138c22..eb7b5ce1 100644 --- a/config/wiring.yaml +++ b/config/wiring.yaml @@ -35,3 +35,26 @@ wiring: source_capability: transcription target_config_id: chronicle-backend-ushadow-purple--leader- target_capability: transcription +<<<<<<< HEAD +======= +- id: a6167961 + source_config_id: openai + source_capability: llm + target_config_id: chronicle-backend + target_capability: llm +- id: 1dd92eb0 + source_config_id: deepgram + source_capability: transcription + target_config_id: chronicle-backend + target_capability: transcription +- id: 08e43d57 + source_config_id: openai + source_capability: llm + target_config_id: mycelia-backend + target_capability: llm +- id: ecef1236 + source_config_id: whisper-local + source_capability: transcription + target_config_id: mycelia-backend + target_capability: transcription +>>>>>>> 0e9fc19e (feat: Add Keycloak SSO integration with conversation sharing) diff --git a/mycelia b/mycelia index fc608c53..9586a3c3 160000 --- a/mycelia +++ b/mycelia @@ -1 +1 @@ -Subproject commit fc608c53b88962781cf17f73856229390ca98973 +Subproject commit 9586a3c332becdee1050069b9a7efe3507ae05e2 diff --git a/share-gateway/Dockerfile b/share-gateway/Dockerfile new file mode 100644 index 00000000..38c45be3 --- /dev/null +++ b/share-gateway/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.12-slim + +WORKDIR /app + +# Install dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application +COPY main.py models.py ./ + +# Expose port +EXPOSE 8000 + +# Run gateway +CMD ["python", "main.py"] diff --git a/share-gateway/README.md b/share-gateway/README.md new file mode 100644 index 00000000..49ab2498 --- /dev/null +++ b/share-gateway/README.md @@ -0,0 +1,159 @@ +# Share Gateway + +Public-facing proxy for accessing ushadow shared resources. + +## Purpose + +This service allows **external users** (not on your Tailscale network) to access shared conversations via share links, while keeping your main ushadow instance completely private. + +## Architecture + +``` +Public Internet + โ†“ +Share Gateway (this service, on public VPS) + โ†“ (via Tailscale) +Your Private Tailnet + โ””โ”€โ”€ ushadow backend +``` + +## Security Model + +- **Only exposes** `/c/{token}` endpoint +- **Validates** share tokens before proxying +- **Rate limited** to 10 requests/minute per IP +- **Audit logs** all access +- **No direct access** to your ushadow APIs +- **Tailscale-secured** connection to backend + +## Deployment + +### Option 1: Public VPS (DigitalOcean, Linode, AWS, etc.) + +1. Create a $5/month VPS +2. Install Tailscale: + ```bash + curl -fsSL https://tailscale.com/install.sh | sh + tailscale up + ``` + +3. Clone this directory to the VPS: + ```bash + scp -r share-gateway/ user@your-vps:/opt/share-gateway + ``` + +4. Configure environment: + ```bash + cat > /opt/share-gateway/.env < bool: + """Check if token has expired.""" + if self.expires_at is None: + return False + return datetime.utcnow() > self.expires_at + + def is_view_limit_exceeded(self) -> bool: + """Check if view limit exceeded.""" + if self.max_views is None: + return False + return self.view_count >= self.max_views + + +class ShareTokenResponse(BaseModel): + """API response model.""" + + token: str + share_url: str + resource_type: str + resource_id: str + permissions: List[str] + expires_at: Optional[datetime] = None + max_views: Optional[int] = None + view_count: int + require_auth: bool + tailscale_only: bool + created_at: datetime diff --git a/share-gateway/requirements.txt b/share-gateway/requirements.txt new file mode 100644 index 00000000..0469a905 --- /dev/null +++ b/share-gateway/requirements.txt @@ -0,0 +1,7 @@ +fastapi==0.115.0 +uvicorn==0.31.1 +httpx==0.27.2 +motor==3.6.0 +pymongo==4.10.1 +pydantic==2.9.2 +slowapi==0.1.9 # Rate limiting diff --git a/ushadow/backend/main.py b/ushadow/backend/main.py index 76278622..7e718d17 100644 --- a/ushadow/backend/main.py +++ b/ushadow/backend/main.py @@ -19,11 +19,12 @@ from motor.motor_asyncio import AsyncIOMotorClient from src.models.user import User # Beanie document model +from src.models.share import ShareToken # Beanie document model from src.routers import health, wizard, chronicle, auth, feature_flags from src.routers import services, deployments, providers, service_configs, chat from src.routers import kubernetes, tailscale, unodes, docker, sse -from src.routers import github_import, audio_relay, memories, keycloak_admin +from src.routers import github_import, audio_relay, memories, share, keycloak_admin from src.routers import settings as settings_api from src.middleware import setup_middleware from src.services.unode_manager import init_unode_manager, get_unode_manager @@ -122,7 +123,7 @@ def send_telemetry(): app.state.db = db # Initialize Beanie ODM with document models - await init_beanie(database=db, document_models=[User]) + await init_beanie(database=db, document_models=[User, ShareToken]) logger.info("โœ“ Beanie ODM initialized") # Create admin user if explicitly configured in secrets.yaml @@ -195,6 +196,7 @@ def send_telemetry(): app.include_router(github_import.router, prefix="/api/github-import", tags=["github-import"]) app.include_router(audio_relay.router, tags=["audio"]) app.include_router(memories.router, tags=["memories"]) +app.include_router(share.router, tags=["sharing"]) app.include_router(keycloak_admin.router, prefix="/api/keycloak", tags=["keycloak-admin"]) # Setup MCP server for LLM tool access diff --git a/ushadow/backend/src/database.py b/ushadow/backend/src/database.py new file mode 100644 index 00000000..00ff78d1 --- /dev/null +++ b/ushadow/backend/src/database.py @@ -0,0 +1,21 @@ +"""Database dependency injection helpers.""" + +from fastapi import Request +from motor.motor_asyncio import AsyncIOMotorDatabase + + +def get_database(request: Request) -> AsyncIOMotorDatabase: + """Get MongoDB database from FastAPI app state. + + Args: + request: FastAPI request object + + Returns: + MongoDB database instance + + Raises: + RuntimeError: If database not initialized + """ + if not hasattr(request.app.state, "db"): + raise RuntimeError("Database not initialized. Check lifespan events in main.py") + return request.app.state.db diff --git a/ushadow/backend/src/models/__init__.py b/ushadow/backend/src/models/__init__.py index 671ec0ea..5f20feb7 100644 --- a/ushadow/backend/src/models/__init__.py +++ b/ushadow/backend/src/models/__init__.py @@ -2,8 +2,24 @@ from .user import User, UserCreate, UserRead, UserUpdate, get_user_db from .provider import EnvMap, Capability, Provider, DockerConfig +from .share import ( + ShareToken, + ShareTokenCreate, + ShareTokenResponse, + ShareAccessLog, + KeycloakPolicy, + ResourceType, + SharePermission, +) __all__ = [ "User", "UserCreate", "UserRead", "UserUpdate", "get_user_db", "EnvMap", "Capability", "Provider", "DockerConfig", + "ShareToken", + "ShareTokenCreate", + "ShareTokenResponse", + "ShareAccessLog", + "KeycloakPolicy", + "ResourceType", + "SharePermission", ] diff --git a/ushadow/backend/src/models/share.py b/ushadow/backend/src/models/share.py new file mode 100644 index 00000000..d2200779 --- /dev/null +++ b/ushadow/backend/src/models/share.py @@ -0,0 +1,289 @@ +"""Share token models for conversation and resource sharing. + +This module provides models for secure sharing of conversations and resources +with fine-grained access control compatible with Keycloak FGA policies. +""" + +import logging +from datetime import datetime +from enum import Enum +from typing import Any, Dict, List, Optional +from uuid import uuid4 + +from beanie import Document, Indexed, PydanticObjectId +from pydantic import BaseModel, Field + +logger = logging.getLogger(__name__) + + +class ResourceType(str, Enum): + """Types of resources that can be shared.""" + + CONVERSATION = "conversation" + MEMORY = "memory" + COLLECTION = "collection" + + +class SharePermission(str, Enum): + """Permission levels for shared resources.""" + + READ = "read" + WRITE = "write" + COMMENT = "comment" + DELETE = "delete" + ADMIN = "admin" + + +class KeycloakPolicy(BaseModel): + """Keycloak-compatible authorization policy. + + Matches Mycelia's policy structure: + {"resource": "conversation:123", "action": "read", "effect": "allow"} + """ + + resource: str = Field(..., description="Resource identifier (e.g., 'conversation:123')") + action: str = Field(..., description="Action/permission (read, write, delete)") + effect: str = Field(default="allow", description="Effect of policy (allow/deny)") + + model_config = {"extra": "forbid"} + + +class ShareToken(Document): + """Share token for secure resource sharing. + + Stores information about shared resources including Keycloak-compatible + policies for fine-grained access control. Supports both authenticated + and anonymous sharing with optional expiration and view limits. + """ + + # Token identification + token: Indexed(str, unique=True) = Field( # type: ignore + default_factory=lambda: str(uuid4()), + description="Unique share token (UUID)", + ) + + # Resource identification + resource_type: str = Field(..., description="Type of shared resource") + resource_id: str = Field(..., description="ID of the shared resource") + + # Ownership + created_by: PydanticObjectId = Field(..., description="User who created the share") + + # Keycloak-compatible policies + policies: List[KeycloakPolicy] = Field( + default_factory=list, + description="Keycloak FGA policies for this share", + ) + + # Permissions (simplified view for API responses) + permissions: List[str] = Field( + default_factory=lambda: ["read"], + description="Simplified permission list (read, write, etc.)", + ) + + # Access control + require_auth: bool = Field( + default=False, + description="If True, user must authenticate to access share", + ) + tailscale_only: bool = Field( + default=False, + description="If True, only accessible from Tailscale network", + ) + allowed_emails: List[str] = Field( + default_factory=list, + description="If non-empty, only these emails can access (when require_auth=True)", + ) + + # Expiration and limits + expires_at: Optional[datetime] = Field( + default=None, + description="When this share expires (None = never)", + ) + max_views: Optional[int] = Field( + default=None, + description="Maximum number of views (None = unlimited)", + ) + view_count: int = Field(default=0, description="Number of times accessed") + + # Audit trail + last_accessed_at: Optional[datetime] = Field( + default=None, + description="Last time this share was accessed", + ) + last_accessed_by: Optional[str] = Field( + default=None, + description="Last user/IP that accessed this share", + ) + access_log: List[Dict[str, Any]] = Field( + default_factory=list, + description="Access audit log (timestamp, user/IP, action)", + ) + + # Timestamps + created_at: datetime = Field(default_factory=datetime.utcnow) + updated_at: datetime = Field(default_factory=datetime.utcnow) + + # Keycloak integration (populated when Keycloak is active) + keycloak_policy_id: Optional[str] = Field( + default=None, + description="Keycloak policy ID if registered with Keycloak FGA", + ) + keycloak_resource_id: Optional[str] = Field( + default=None, + description="Keycloak resource ID if registered", + ) + + class Settings: + """Beanie document settings.""" + + name = "share_tokens" + indexes = [ + "token", # Fast lookup by token + "resource_type", + "resource_id", + "created_by", + "expires_at", + [("resource_type", 1), ("resource_id", 1)], # Compound index + ] + + def is_expired(self) -> bool: + """Check if share token has expired.""" + if self.expires_at is None: + return False + return datetime.utcnow() > self.expires_at + + def is_view_limit_exceeded(self) -> bool: + """Check if view limit has been exceeded.""" + if self.max_views is None: + return False + return self.view_count >= self.max_views + + def can_access(self, user_email: Optional[str] = None) -> tuple[bool, str]: + """Check if access is allowed. + + Args: + user_email: Email of user trying to access (None for anonymous) + + Returns: + Tuple of (allowed: bool, reason: str) + """ + if self.is_expired(): + return False, "Share link has expired" + + if self.is_view_limit_exceeded(): + return False, "Share link view limit exceeded" + + if self.require_auth and user_email is None: + return False, "Authentication required" + + if self.allowed_emails and user_email not in self.allowed_emails: + return False, f"Access restricted to specific users" + + return True, "Access granted" + + def has_permission(self, permission: str) -> bool: + """Check if token grants specific permission.""" + return permission in self.permissions + + async def record_access( + self, + user_identifier: str, + action: str = "view", + metadata: Optional[Dict[str, Any]] = None, + ): + """Record access to shared resource. + + Args: + user_identifier: Email or IP address of accessor + action: Action performed (view, edit, etc.) + metadata: Additional context (user agent, IP, etc.) + """ + self.view_count += 1 + self.last_accessed_at = datetime.utcnow() + self.last_accessed_by = user_identifier + self.updated_at = datetime.utcnow() + + # Add to audit log + log_entry = { + "timestamp": datetime.utcnow(), + "user_identifier": user_identifier, + "action": action, + "view_count": self.view_count, + } + if metadata: + log_entry["metadata"] = metadata + + self.access_log.append(log_entry) + await self.save() + + +class ShareTokenCreate(BaseModel): + """Request model for creating a share token.""" + + resource_type: ResourceType = Field(..., description="Type of resource to share") + resource_id: str = Field(..., min_length=1, description="ID of resource to share") + + permissions: List[SharePermission] = Field( + default=[SharePermission.READ], + description="Permissions to grant", + ) + + # Access control + require_auth: bool = Field( + default=False, + description="Require authentication to access", + ) + tailscale_only: bool = Field( + default=False, + description="Only accessible from Tailscale network", + ) + allowed_emails: List[str] = Field( + default_factory=list, + description="Restrict access to specific email addresses", + ) + + # Expiration + expires_in_days: Optional[int] = Field( + default=None, + ge=1, + le=365, + description="Number of days until expiration (None = never)", + ) + max_views: Optional[int] = Field( + default=None, + ge=1, + description="Maximum number of views (None = unlimited)", + ) + + model_config = {"extra": "forbid"} + + +class ShareTokenResponse(BaseModel): + """Response model for share token information.""" + + token: str + share_url: str + resource_type: str + resource_id: str + permissions: List[str] + expires_at: Optional[datetime] = None + max_views: Optional[int] = None + view_count: int + require_auth: bool + tailscale_only: bool + created_at: datetime + + model_config = {"extra": "forbid"} + + +class ShareAccessLog(BaseModel): + """Access log entry for share token.""" + + timestamp: datetime + user_identifier: str + action: str + view_count: int + metadata: Optional[Dict[str, Any]] = None + + model_config = {"extra": "forbid"} diff --git a/ushadow/backend/src/models/user.py b/ushadow/backend/src/models/user.py index 57b00eb4..c6138d92 100644 --- a/ushadow/backend/src/models/user.py +++ b/ushadow/backend/src/models/user.py @@ -75,6 +75,12 @@ class User(BeanieBaseUser, Document): created_at: datetime = Field(default_factory=datetime.utcnow) updated_at: datetime = Field(default_factory=datetime.utcnow) + # Keycloak integration field + keycloak_id: Optional[str] = Field( + default=None, + description="Keycloak user UUID (sub claim) for federated users" + ) + class Settings: name = "users" # MongoDB collection name email_collation = {"locale": "en", "strength": 2} # Case-insensitive email diff --git a/ushadow/backend/src/routers/auth.py b/ushadow/backend/src/routers/auth.py index 11e48ca7..cbed18f4 100644 --- a/ushadow/backend/src/routers/auth.py +++ b/ushadow/backend/src/routers/auth.py @@ -358,7 +358,7 @@ async def logout( user: User = Depends(get_current_user), ): """Logout current user by clearing the auth cookie. - + Note: For bearer tokens, logout is handled client-side by discarding the token. This endpoint clears the HTTP-only cookie. """ @@ -367,6 +367,108 @@ async def logout( httponly=True, samesite="lax", ) + + +# Keycloak OAuth Token Exchange +class TokenExchangeRequest(BaseModel): + """Request for exchanging OAuth authorization code for tokens.""" + code: str = Field(..., description="Authorization code from Keycloak") + code_verifier: str = Field(..., description="PKCE code verifier") + redirect_uri: str = Field(..., description="Redirect URI used in authorization request") + + +class TokenExchangeResponse(BaseModel): + """Response containing OAuth tokens.""" + access_token: str + refresh_token: Optional[str] = None + id_token: Optional[str] = None + expires_in: Optional[int] = None + token_type: str = "Bearer" + + +@router.post("/token", response_model=TokenExchangeResponse) +async def exchange_code_for_tokens(request: TokenExchangeRequest): + """Exchange OAuth authorization code for access/refresh tokens. + + This endpoint implements the OAuth 2.0 Authorization Code Flow with PKCE. + It exchanges the authorization code received from Keycloak for actual tokens. + + Args: + request: Contains authorization code, PKCE verifier, and redirect URI + + Returns: + Access token, refresh token, and ID token from Keycloak + + Raises: + 400: If code exchange fails (invalid code, expired, etc.) + 503: If Keycloak is unreachable + """ + import httpx + from src.config.keycloak_settings import get_keycloak_config + + try: + # Get Keycloak configuration + kc_config = get_keycloak_config() + + if not kc_config.get("enabled"): + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Keycloak authentication is not enabled" + ) + + # Prepare token exchange request to Keycloak + token_url = f"{kc_config['url']}/realms/{kc_config['realm']}/protocol/openid-connect/token" + + token_data = { + "grant_type": "authorization_code", + "code": request.code, + "redirect_uri": request.redirect_uri, + "client_id": kc_config["frontend_client_id"], + "code_verifier": request.code_verifier, + } + + logger.info(f"[TOKEN-EXCHANGE] Exchanging code with Keycloak at {token_url}") + + # Make request to Keycloak + async with httpx.AsyncClient() as client: + response = await client.post( + token_url, + data=token_data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + timeout=10.0 + ) + + if response.status_code != 200: + error_detail = response.text + logger.error(f"[TOKEN-EXCHANGE] Keycloak error: {error_detail}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Token exchange failed: {error_detail}" + ) + + tokens = response.json() + logger.info(f"[TOKEN-EXCHANGE] โœ“ Successfully exchanged code for tokens") + + return TokenExchangeResponse( + access_token=tokens["access_token"], + refresh_token=tokens.get("refresh_token"), + id_token=tokens.get("id_token"), + expires_in=tokens.get("expires_in"), + token_type=tokens.get("token_type", "Bearer") + ) + + except httpx.RequestError as e: + logger.error(f"[TOKEN-EXCHANGE] Failed to connect to Keycloak: {e}") + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Cannot connect to Keycloak authentication server" + ) + except Exception as e: + logger.error(f"[TOKEN-EXCHANGE] Unexpected error: {e}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=str(e) + ) logger.info(f"User logged out: {user.email}") return {"message": "Successfully logged out"} diff --git a/ushadow/backend/src/routers/services.py b/ushadow/backend/src/routers/services.py index b6332c6b..99428c51 100644 --- a/ushadow/backend/src/routers/services.py +++ b/ushadow/backend/src/routers/services.py @@ -726,6 +726,20 @@ async def proxy_service_request( logger.info(f"[PROXY] Token payload: iss={payload.get('iss')}, aud={payload.get('aud')}, sub={payload.get('sub')}") except Exception as e: logger.debug(f"[PROXY] Could not decode token: {e}") + + # Bridge Keycloak tokens to service tokens for Chronicle + from src.services.token_bridge import bridge_to_service_token + token_without_bearer = auth_header.replace("Bearer ", "") + service_token = await bridge_to_service_token( + token_without_bearer, + audiences=["ushadow", "chronicle"] + ) + if service_token and service_token != token_without_bearer: + # Token was bridged (Keycloak โ†’ service token) + headers["authorization"] = f"Bearer {service_token}" + logger.info(f"[PROXY] โœ“ Bridged Keycloak token to service token") + else: + logger.debug(f"[PROXY] Token passed through (already a service token or bridging failed)") else: logger.warning(f"[PROXY] No Authorization header in request to {name}") diff --git a/ushadow/backend/src/routers/share.py b/ushadow/backend/src/routers/share.py new file mode 100644 index 00000000..191d65aa --- /dev/null +++ b/ushadow/backend/src/routers/share.py @@ -0,0 +1,321 @@ +"""Share API endpoints for conversation and resource sharing. + +Provides HTTP endpoints for creating, accessing, and managing share tokens. +Thin router layer that delegates to ShareService for business logic. +""" + +import logging +import os +from typing import List, Optional + +from fastapi import APIRouter, Depends, HTTPException, Request +from motor.motor_asyncio import AsyncIOMotorDatabase + +from ..database import get_database +from .tailscale import _read_config as read_tailscale_config +from ..models.share import ( + ShareAccessLog, + ShareToken, + ShareTokenCreate, + ShareTokenResponse, +) +from ..models.user import User +from ..services.auth import get_current_user, get_optional_current_user +from ..services.share_service import ShareService + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/share", tags=["sharing"]) + + +def _get_share_base_url() -> str: + """Determine the base URL for share links. + + Strategy hierarchy: + 1. SHARE_BASE_URL environment variable (highest priority) + 2. SHARE_PUBLIC_GATEWAY environment variable (for external sharing) + 3. Tailscale hostname (for Tailnet-only sharing) + 4. Fallback to localhost (development only) + + Returns: + Base URL string (e.g., "https://ushadow.tail12345.ts.net" or "https://share.yourdomain.com") + """ + # Explicit override (highest priority) + if base_url := os.getenv("SHARE_BASE_URL"): + logger.info(f"Using explicit SHARE_BASE_URL: {base_url}") + return base_url.rstrip("/") + + # Public gateway for external sharing + if gateway_url := os.getenv("SHARE_PUBLIC_GATEWAY"): + logger.info(f"Using public gateway: {gateway_url}") + return gateway_url.rstrip("/") + + # Use Tailscale hostname (works with or without Funnel) + try: + config = read_tailscale_config() + if config and config.hostname: + tailscale_url = f"https://{config.hostname}" + logger.info(f"Using Tailscale hostname: {tailscale_url}") + return tailscale_url + except Exception as e: + logger.warning(f"Failed to read Tailscale config: {e}") + + # Fallback for development + logger.warning("Using localhost fallback - shares will only work locally!") + return "http://localhost:3000" + + +def get_share_service(db: AsyncIOMotorDatabase = Depends(get_database)) -> ShareService: + """Dependency injection for ShareService. + + Args: + db: MongoDB database (injected) + + Returns: + ShareService instance + """ + base_url = _get_share_base_url() + logger.info(f"Share service initialized with base_url: {base_url}") + return ShareService(db=db, base_url=base_url) + + +@router.post("/create", response_model=ShareTokenResponse, status_code=201) +async def create_share_token( + data: ShareTokenCreate, + current_user: User = Depends(get_current_user), + service: ShareService = Depends(get_share_service), +) -> ShareTokenResponse: + """Create a new share token for a resource. + + Requires authentication. User must have permission to share the resource. + + Args: + data: Share token creation parameters + current_user: Authenticated user + service: Share service instance + + Returns: + Created share token with share URL + + Raises: + 400: If resource doesn't exist or user lacks permission + 401: If not authenticated + """ + try: + share_token = await service.create_share_token(data, current_user) + return service.to_response(share_token) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.get("/{token}", response_model=dict) +async def access_shared_resource( + token: str, + request: Request, + current_user: Optional[User] = Depends(get_optional_current_user), + service: ShareService = Depends(get_share_service), +) -> dict: + """Access a shared resource via share token. + + Public endpoint - does not require authentication unless share requires it. + Records access in audit log. + + Args: + token: Share token UUID + request: HTTP request (for IP address) + current_user: Optional authenticated user + service: Share service instance + + Returns: + Shared resource data with permissions + + Raises: + 403: If access denied (expired, limit exceeded, etc.) + 404: If share token not found + """ + # Get user email if authenticated + user_email = current_user.email if current_user else None + + # Get request IP for Tailscale validation + request_ip = request.client.host if request.client else None + + # Validate access + is_valid, share_token, reason = await service.validate_share_access( + token=token, + user_email=user_email, + request_ip=request_ip, + ) + + if not is_valid: + if share_token is None: + raise HTTPException(status_code=404, detail="Share token not found") + raise HTTPException(status_code=403, detail=reason) + + # Record access + user_identifier = user_email or request_ip or "anonymous" + metadata = { + "ip": request_ip, + "user_agent": request.headers.get("user-agent"), + } + await service.record_share_access( + share_token=share_token, + user_identifier=user_identifier, + action="view", + metadata=metadata, + ) + + # TODO: Fetch actual resource data from Chronicle/Mycelia + # For now, return share token info and placeholder resource + return { + "share_token": service.to_response(share_token).dict(), + "resource": { + "type": share_token.resource_type, + "id": share_token.resource_id, + # TODO: Add actual resource data here + "data": f"Placeholder for {share_token.resource_type}:{share_token.resource_id}", + }, + "permissions": share_token.permissions, + } + + +@router.delete("/{token}", status_code=204) +async def revoke_share_token( + token: str, + current_user: User = Depends(get_current_user), + service: ShareService = Depends(get_share_service), +): + """Revoke a share token. + + Requires authentication. User must be the creator or admin. + + Args: + token: Share token to revoke + current_user: Authenticated user + service: Share service instance + + Raises: + 403: If user lacks permission + 404: If share token not found + """ + try: + revoked = await service.revoke_share_token(token, current_user) + if not revoked: + raise HTTPException(status_code=404, detail="Share token not found") + except ValueError as e: + raise HTTPException(status_code=403, detail=str(e)) + + +@router.get("/resource/{resource_type}/{resource_id}", response_model=List[ShareTokenResponse]) +async def list_shares_for_resource( + resource_type: str, + resource_id: str, + current_user: User = Depends(get_current_user), + service: ShareService = Depends(get_share_service), +) -> List[ShareTokenResponse]: + """List all share tokens for a resource. + + Requires authentication. User must have access to the resource. + + Args: + resource_type: Type of resource (conversation, memory, etc.) + resource_id: ID of resource + current_user: Authenticated user + service: Share service instance + + Returns: + List of share tokens for the resource + """ + share_tokens = await service.list_shares_for_resource( + resource_type=resource_type, + resource_id=resource_id, + user=current_user, + ) + return [service.to_response(token) for token in share_tokens] + + +@router.get("/{token}/logs", response_model=List[ShareAccessLog]) +async def get_share_access_logs( + token: str, + current_user: User = Depends(get_current_user), + service: ShareService = Depends(get_share_service), +) -> List[ShareAccessLog]: + """Get access logs for a share token. + + Requires authentication. User must be creator or admin. + + Args: + token: Share token + current_user: Authenticated user + service: Share service instance + + Returns: + List of access log entries + + Raises: + 403: If user lacks permission + 404: If share token not found + """ + try: + return await service.get_share_access_logs(token, current_user) + except ValueError as e: + if "not found" in str(e).lower(): + raise HTTPException(status_code=404, detail=str(e)) + raise HTTPException(status_code=403, detail=str(e)) + + +# Convenience endpoints for specific resource types + +@router.post("/conversations/{conversation_id}", response_model=ShareTokenResponse, status_code=201) +async def share_conversation( + conversation_id: str, + data: ShareTokenCreate, + current_user: User = Depends(get_current_user), + service: ShareService = Depends(get_share_service), +) -> ShareTokenResponse: + """Convenience endpoint for sharing a conversation. + + Automatically sets resource_type to 'conversation' and uses path parameter + for resource_id. Otherwise identical to POST /api/share/create. + + Args: + conversation_id: ID of conversation to share + data: Share token parameters (resource_type/resource_id will be overridden) + current_user: Authenticated user + service: Share service instance + + Returns: + Created share token with share URL + """ + # Override resource type and ID from path + data.resource_type = "conversation" + data.resource_id = conversation_id + + try: + share_token = await service.create_share_token(data, current_user) + return service.to_response(share_token) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.get("/conversations/{conversation_id}/shares", response_model=List[ShareTokenResponse]) +async def list_conversation_shares( + conversation_id: str, + current_user: User = Depends(get_current_user), + service: ShareService = Depends(get_share_service), +) -> List[ShareTokenResponse]: + """Convenience endpoint for listing shares of a conversation. + + Args: + conversation_id: ID of conversation + current_user: Authenticated user + service: Share service instance + + Returns: + List of share tokens for the conversation + """ + share_tokens = await service.list_shares_for_resource( + resource_type="conversation", + resource_id=conversation_id, + user=current_user, + ) + return [service.to_response(token) for token in share_tokens] diff --git a/ushadow/backend/src/services/auth.py b/ushadow/backend/src/services/auth.py index 11d2e8c7..1c54f203 100644 --- a/ushadow/backend/src/services/auth.py +++ b/ushadow/backend/src/services/auth.py @@ -240,7 +240,14 @@ async def read_token( ) # User dependencies for protecting endpoints -get_current_user = fastapi_users.current_user(active=True) +# Import hybrid auth dependency that accepts both legacy JWT and Keycloak tokens +from src.services.keycloak_auth import get_current_user_hybrid + +# Use hybrid authentication for all endpoints (supports both legacy and Keycloak) +get_current_user = get_current_user_hybrid + +# Legacy fastapi-users dependencies (kept for backwards compatibility if needed) +_legacy_get_current_user = fastapi_users.current_user(active=True) get_optional_current_user = fastapi_users.current_user(active=True, optional=True) get_current_superuser = fastapi_users.current_user(active=True, superuser=True) diff --git a/ushadow/backend/src/services/keycloak_user_sync.py b/ushadow/backend/src/services/keycloak_user_sync.py index 7a767473..ae66ee98 100644 --- a/ushadow/backend/src/services/keycloak_user_sync.py +++ b/ushadow/backend/src/services/keycloak_user_sync.py @@ -49,10 +49,17 @@ async def get_or_create_user_from_keycloak( if user: logger.info(f"[KC-USER-SYNC] Found existing user: {email} (MongoDB ID: {user.id})") +<<<<<<< HEAD # Update name if it changed if name and user.name != name: logger.info(f"[KC-USER-SYNC] Updating name: {user.name} โ†’ {name}") user.name = name +======= + # Update display_name if it changed + if name and user.display_name != name: + logger.info(f"[KC-USER-SYNC] Updating display_name: {user.display_name} โ†’ {name}") + user.display_name = name +>>>>>>> 0e9fc19e (feat: Add Keycloak SSO integration with conversation sharing) await user.save() return user @@ -66,8 +73,13 @@ async def get_or_create_user_from_keycloak( # Link to Keycloak user.keycloak_id = keycloak_sub +<<<<<<< HEAD if name and not user.name: user.name = name +======= + if name and not user.display_name: + user.display_name = name +>>>>>>> 0e9fc19e (feat: Add Keycloak SSO integration with conversation sharing) await user.save() return user @@ -77,7 +89,11 @@ async def get_or_create_user_from_keycloak( user = User( email=email, +<<<<<<< HEAD name=name or email, # Fallback to email if no name provided +======= + display_name=name or email, # Fallback to email if no name provided +>>>>>>> 0e9fc19e (feat: Add Keycloak SSO integration with conversation sharing) keycloak_id=keycloak_sub, is_active=True, is_verified=True, # Keycloak users are pre-verified diff --git a/ushadow/backend/src/services/share_service.py b/ushadow/backend/src/services/share_service.py new file mode 100644 index 00000000..bd97d4e5 --- /dev/null +++ b/ushadow/backend/src/services/share_service.py @@ -0,0 +1,512 @@ +"""Share service for conversation and resource sharing. + +Implements business logic for creating, validating, and managing share tokens +with Keycloak Fine-Grained Authorization (FGA) integration. +""" + +import logging +from datetime import datetime, timedelta +from typing import Any, Dict, List, Optional +from uuid import uuid4 + +from beanie import PydanticObjectId +from motor.motor_asyncio import AsyncIOMotorDatabase + +from ..models.share import ( + KeycloakPolicy, + ResourceType, + ShareAccessLog, + SharePermission, + ShareToken, + ShareTokenCreate, + ShareTokenResponse, +) +from ..models.user import User + +logger = logging.getLogger(__name__) + + +class ShareService: + """Service for managing share tokens and access control. + + Coordinates share token creation, validation, and Keycloak FGA integration. + Implements business rules for expiration, view limits, and permission checking. + """ + + def __init__(self, db: AsyncIOMotorDatabase, base_url: str = "http://localhost:3000"): + """Initialize share service. + + Args: + db: MongoDB database instance + base_url: Base URL for generating share links (e.g., "https://ushadow.example.com") + """ + self.db = db + self.base_url = base_url.rstrip("/") + + async def create_share_token( + self, + data: ShareTokenCreate, + created_by: User, + ) -> ShareToken: + """Create a new share token. + + Args: + data: Share token creation parameters + created_by: User creating the share + + Returns: + Created share token + + Raises: + ValueError: If resource doesn't exist or user lacks permission + """ + # TODO: Validate resource exists and user has permission to share it + # This is a business logic decision point - should we verify ownership here? + # Consider: strict ownership check vs. allowing sharing of any accessible resource + await self._validate_resource_exists(data.resource_type, data.resource_id) + await self._validate_user_can_share(created_by, data.resource_type, data.resource_id) + + # Calculate expiration + expires_at = None + if data.expires_in_days: + expires_at = datetime.utcnow() + timedelta(days=data.expires_in_days) + + # Build Keycloak-compatible policies + policies = self._build_keycloak_policies( + resource_type=data.resource_type.value, + resource_id=data.resource_id, + permissions=[p.value for p in data.permissions], + ) + + # Create share token + share_token = ShareToken( + token=str(uuid4()), + resource_type=data.resource_type.value, + resource_id=data.resource_id, + created_by=created_by.id, + policies=policies, + permissions=[p.value for p in data.permissions], + require_auth=data.require_auth, + tailscale_only=data.tailscale_only, + allowed_emails=data.allowed_emails, + expires_at=expires_at, + max_views=data.max_views, + ) + + await share_token.insert() + + # TODO: Register with Keycloak FGA if enabled + # await self._register_with_keycloak(share_token) + + logger.info( + f"Created share token {share_token.token} for {data.resource_type}:{data.resource_id} " + f"by user {created_by.email}" + ) + + return share_token + + async def get_share_token(self, token: str) -> Optional[ShareToken]: + """Get share token by token string. + + Args: + token: Share token UUID + + Returns: + ShareToken if found, None otherwise + """ + return await ShareToken.find_one(ShareToken.token == token) + + async def validate_share_access( + self, + token: str, + user_email: Optional[str] = None, + request_ip: Optional[str] = None, + ) -> tuple[bool, Optional[ShareToken], str]: + """Validate access to a shared resource. + + Args: + token: Share token string + user_email: Email of user trying to access (None for anonymous) + request_ip: IP address of request (for Tailscale validation) + + Returns: + Tuple of (is_valid, share_token, reason) + """ + share_token = await self.get_share_token(token) + if not share_token: + return False, None, "Invalid share token" + + # Check access permissions + can_access, reason = share_token.can_access(user_email) + if not can_access: + return False, share_token, reason + + # TODO: Validate Tailscale network if required + # This is a decision point - how should we verify Tailscale access? + # Options: check IP ranges, validate via Tailscale API, trust reverse proxy headers + if share_token.tailscale_only: + is_tailscale = await self._validate_tailscale_access(request_ip) + if not is_tailscale: + return False, share_token, "Access restricted to Tailscale network" + + return True, share_token, "Access granted" + + async def record_share_access( + self, + share_token: ShareToken, + user_identifier: str, + action: str = "view", + metadata: Optional[Dict[str, Any]] = None, + ): + """Record access to shared resource for audit trail. + + Args: + share_token: Share token being accessed + user_identifier: Email or IP of accessor + action: Action performed (view, edit, etc.) + metadata: Additional context (user agent, IP, etc.) + """ + await share_token.record_access(user_identifier, action, metadata) + logger.info( + f"Recorded {action} access to share {share_token.token} " + f"by {user_identifier} (view {share_token.view_count})" + ) + + async def revoke_share_token(self, token: str, user: User) -> bool: + """Revoke a share token. + + Args: + token: Share token to revoke + user: User attempting to revoke + + Returns: + True if revoked, False if not found or permission denied + + Raises: + ValueError: If user lacks permission to revoke + """ + share_token = await self.get_share_token(token) + if not share_token: + return False + + # Verify user can revoke (must be creator or admin) + if str(share_token.created_by) != str(user.id) and not user.is_superuser: + raise ValueError("Only the creator or admin can revoke share tokens") + + # TODO: Unregister from Keycloak FGA if enabled + # await self._unregister_from_keycloak(share_token) + + await share_token.delete() + logger.info(f"Revoked share token {token} by user {user.email}") + return True + + async def list_shares_for_resource( + self, + resource_type: str, + resource_id: str, + user: User, + ) -> List[ShareToken]: + """List all share tokens for a resource. + + Args: + resource_type: Type of resource + resource_id: ID of resource + user: User requesting list (must have access to resource) + + Returns: + List of share tokens + """ + # TODO: Validate user has access to resource + # await self._validate_user_can_access(user, resource_type, resource_id) + + return await ShareToken.find( + ShareToken.resource_type == resource_type, + ShareToken.resource_id == resource_id, + ).to_list() + + async def get_share_access_logs( + self, + token: str, + user: User, + ) -> List[ShareAccessLog]: + """Get access logs for a share token. + + Args: + token: Share token + user: User requesting logs (must be creator or admin) + + Returns: + List of access log entries + + Raises: + ValueError: If user lacks permission + """ + share_token = await self.get_share_token(token) + if not share_token: + raise ValueError("Share token not found") + + # Verify permission + if str(share_token.created_by) != str(user.id) and not user.is_superuser: + raise ValueError("Only the creator or admin can view access logs") + + return [ShareAccessLog(**log) for log in share_token.access_log] + + def to_response(self, share_token: ShareToken) -> ShareTokenResponse: + """Convert ShareToken to API response model. + + Args: + share_token: Share token document + + Returns: + ShareTokenResponse for API + """ + return ShareTokenResponse( + token=share_token.token, + share_url=f"{self.base_url}/share/{share_token.token}", + resource_type=share_token.resource_type, + resource_id=share_token.resource_id, + permissions=share_token.permissions, + expires_at=share_token.expires_at, + max_views=share_token.max_views, + view_count=share_token.view_count, + require_auth=share_token.require_auth, + tailscale_only=share_token.tailscale_only, + created_at=share_token.created_at, + ) + + # Private helper methods + + def _build_keycloak_policies( + self, + resource_type: str, + resource_id: str, + permissions: List[str], + ) -> List[KeycloakPolicy]: + """Build Keycloak FGA policies from permissions. + + Args: + resource_type: Type of resource + resource_id: ID of resource + permissions: List of permission strings (read, write, etc.) + + Returns: + List of Keycloak-compatible policies + """ + # Resource identifier format: "type:id" (e.g., "conversation:123") + resource = f"{resource_type}:{resource_id}" + + return [ + KeycloakPolicy( + resource=resource, + action=permission, + effect="allow", + ) + for permission in permissions + ] + + async def _validate_resource_exists( + self, + resource_type: ResourceType, + resource_id: str, + ): + """Validate that resource exists and is accessible. + + Args: + resource_type: Type of resource + resource_id: ID of resource + + Raises: + ValueError: If resource doesn't exist + """ + import httpx + import os + + # Configuration: Enable/disable strict validation + ENABLE_VALIDATION = os.getenv("SHARE_VALIDATE_RESOURCES", "false").lower() == "true" + + if not ENABLE_VALIDATION: + # Lazy validation - skip check for faster share creation + logger.debug(f"Skipping validation for {resource_type}:{resource_id} (SHARE_VALIDATE_RESOURCES=false)") + return + + # Strict validation - verify resource exists + logger.debug(f"Validating resource {resource_type}:{resource_id}") + + # TODO: YOUR IMPLEMENTATION (5-10 lines) + # Implement validation logic based on your backend choice: + # + # For Mycelia (resource-based API): + # POST to /api/resource/tech.mycelia.objects with action: "get", id: resource_id + # + # For Chronicle (REST API): + # GET /api/conversations/{resource_id} + # + # Example structure: + # if resource_type == ResourceType.CONVERSATION: + # # Your validation code here + # pass + # elif resource_type == ResourceType.MEMORY: + # # Memory validation + # pass + + # Placeholder: Log that validation needs implementation + logger.warning( + f"Resource validation is enabled but not implemented for {resource_type}. " + f"Add validation logic in share_service.py:_validate_resource_exists()" + ) + + async def _validate_user_can_share( + self, + user: User, + resource_type: ResourceType, + resource_id: str, + ): + """Validate user has permission to share resource. + + Business rule: Users can only share resources they created (ownership-based). + + Args: + user: User attempting to share + resource_type: Type of resource + resource_id: ID of resource + + Raises: + ValueError: If user lacks permission (not the owner) + """ + import httpx + import os + + # Superusers can share anything + if user.is_superuser: + logger.debug(f"Superuser {user.email} granted share permission for {resource_type}:{resource_id}") + return + + # For conversations/objects in Mycelia, verify ownership + if resource_type == ResourceType.CONVERSATION: + mycelia_url = os.getenv("MYCELIA_URL", "http://mycelia-backend:8000") + + try: + # Fetch the object from Mycelia to check userId field + async with httpx.AsyncClient(timeout=5.0) as client: + response = await client.post( + f"{mycelia_url}/api/resource/tech.mycelia.objects", + json={ + "action": "get", + "id": resource_id + }, + # TODO: Add authentication header if needed + # headers={"Authorization": f"Bearer {token}"} + ) + + if response.status_code == 404: + raise ValueError(f"Conversation {resource_id} not found") + elif response.status_code != 200: + logger.error(f"Failed to fetch resource for ownership check: {response.status_code}") + raise ValueError("Could not verify resource ownership") + + resource_data = response.json() + + # Check if user owns this resource + # Mycelia stores userId field on objects + resource_owner = resource_data.get("userId") + if not resource_owner: + logger.warning(f"Resource {resource_id} has no userId field, allowing share") + return # Allow if no owner specified + + # Compare owner with current user + # User email is used as the userId in Mycelia + if resource_owner != user.email: + raise ValueError( + f"You can only share conversations you created. " + f"This conversation belongs to {resource_owner}" + ) + + logger.debug(f"User {user.email} verified as owner of {resource_type}:{resource_id}") + + except httpx.RequestError as e: + logger.error(f"Failed to connect to Mycelia for ownership check: {e}") + raise ValueError("Could not verify resource ownership - Mycelia unavailable") + + elif resource_type == ResourceType.MEMORY: + # TODO: Implement memory ownership check if needed + # For now, allow authenticated users to share memories + logger.debug(f"Memory sharing not yet enforcing ownership for {resource_id}") + + else: + # Other resource types - allow for now + logger.debug(f"Resource type {resource_type} ownership check not implemented") + + async def _validate_tailscale_access(self, request_ip: Optional[str]) -> bool: + """Validate request is from Tailscale network. + + Args: + request_ip: IP address of request + + Returns: + True if from Tailscale, False otherwise + """ + import ipaddress + import os + + # Configuration: Enable/disable Tailscale validation + ENABLE_TAILSCALE_CHECK = os.getenv("SHARE_VALIDATE_TAILSCALE", "false").lower() == "true" + + if not ENABLE_TAILSCALE_CHECK: + # Disabled - allow all IPs (useful for testing or when not using Tailscale) + logger.debug(f"Tailscale validation disabled (SHARE_VALIDATE_TAILSCALE=false)") + return True + + if not request_ip: + logger.warning("No request IP provided for Tailscale validation") + return False + + # TODO: YOUR IMPLEMENTATION (5-10 lines) + # Choose your Tailscale validation strategy based on your setup: + # + # Option A - IP Range Check (if ushadow runs directly on Tailscale): + # try: + # ip = ipaddress.ip_address(request_ip) + # tailscale_range = ipaddress.ip_network("100.64.0.0/10") + # is_tailscale = ip in tailscale_range + # logger.debug(f"IP {request_ip} {'is' if is_tailscale else 'is NOT'} in Tailscale range") + # return is_tailscale + # except ValueError: + # logger.warning(f"Invalid IP address: {request_ip}") + # return False + # + # Option B - Trust Tailscale Serve Headers (if using Tailscale Serve): + # # This requires passing the Request object instead of just IP + # # tailscale_user = request.headers.get("X-Tailscale-User") + # # return tailscale_user is not None + # + # For now, log a warning and allow (fail open for testing) + logger.warning( + f"Tailscale validation enabled but not implemented. " + f"Add logic in share_service.py:_validate_tailscale_access(). " + f"IP: {request_ip}" + ) + return True # Fail open until implemented + + async def _register_with_keycloak(self, share_token: ShareToken): + """Register share token with Keycloak FGA. + + Args: + share_token: Share token to register + """ + # TODO: Implement Keycloak FGA registration + # This should: + # 1. Create Keycloak resource for the shared item + # 2. Create Keycloak authorization policies + # 3. Store keycloak_policy_id and keycloak_resource_id on share_token + logger.debug(f"Keycloak FGA registration for token {share_token.token}") + + async def _unregister_from_keycloak(self, share_token: ShareToken): + """Unregister share token from Keycloak FGA. + + Args: + share_token: Share token to unregister + """ + # TODO: Implement Keycloak FGA cleanup + # This should delete the Keycloak resource and policies + if share_token.keycloak_policy_id: + logger.debug(f"Keycloak FGA cleanup for policy {share_token.keycloak_policy_id}") diff --git a/ushadow/frontend/package-lock.json b/ushadow/frontend/package-lock.json index be4862ed..f27be46f 100644 --- a/ushadow/frontend/package-lock.json +++ b/ushadow/frontend/package-lock.json @@ -19,6 +19,10 @@ "axios": "^1.7.7", "d3": "^7.9.0", "frappe-gantt": "^1.0.4", +<<<<<<< HEAD +======= + "jwt-decode": "^4.0.0", +>>>>>>> 0e9fc19e (feat: Add Keycloak SSO integration with conversation sharing) "lucide-react": "^0.446.0", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -5578,6 +5582,15 @@ "node": ">=6" } }, + "node_modules/jwt-decode": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", + "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", diff --git a/ushadow/frontend/package.json b/ushadow/frontend/package.json index 530f1abb..260d0475 100644 --- a/ushadow/frontend/package.json +++ b/ushadow/frontend/package.json @@ -27,6 +27,7 @@ "axios": "^1.7.7", "d3": "^7.9.0", "frappe-gantt": "^1.0.4", + "jwt-decode": "^4.0.0", "lucide-react": "^0.446.0", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/ushadow/frontend/src/App.tsx b/ushadow/frontend/src/App.tsx index 80aaedca..b02ece35 100644 --- a/ushadow/frontend/src/App.tsx +++ b/ushadow/frontend/src/App.tsx @@ -2,6 +2,7 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' import { ErrorBoundary } from './components/ErrorBoundary' import { ThemeProvider } from './contexts/ThemeContext' import { AuthProvider, useAuth } from './contexts/AuthContext' +import { KeycloakAuthProvider } from './contexts/KeycloakAuthContext' import { FeatureFlagsProvider } from './contexts/FeatureFlagsContext' import { WizardProvider } from './contexts/WizardContext' import { ChronicleProvider } from './contexts/ChronicleContext' @@ -28,6 +29,7 @@ import Layout from './components/layout/Layout' import RegistrationPage from './pages/RegistrationPage' import LoginPage from './pages/LoginPage' import ErrorPage from './pages/ErrorPage' +import OAuthCallback from './auth/OAuthCallback' import Dashboard from './pages/Dashboard' import WizardStartPage from './pages/WizardStartPage' import ChroniclePage from './pages/ChroniclePage' @@ -90,6 +92,7 @@ function AppContent() { {/* Public Routes */} } /> } /> + } /> } /> {/* Protected Routes - All wrapped in Layout */} @@ -156,6 +159,7 @@ function App() { +<<<<<<< HEAD @@ -163,6 +167,17 @@ function App() { +======= + + + + + + + + + +>>>>>>> 0e9fc19e (feat: Add Keycloak SSO integration with conversation sharing) diff --git a/ushadow/frontend/src/auth/OAuthCallback.tsx b/ushadow/frontend/src/auth/OAuthCallback.tsx index f59e3cda..75178cf5 100644 --- a/ushadow/frontend/src/auth/OAuthCallback.tsx +++ b/ushadow/frontend/src/auth/OAuthCallback.tsx @@ -45,14 +45,23 @@ export default function OAuthCallback() { throw new Error('Missing state parameter') } + console.log('[OAuthCallback] ๐Ÿ“ Code extracted, clearing URL to prevent reuse...') + // CRITICAL: Clear the URL params immediately to prevent the code from being reused + // if this component remounts (which can happen in React StrictMode or during navigation) + window.history.replaceState({}, document.title, window.location.pathname) + // Exchange code for tokens (includes state verification) await handleCallback(code, state) - // Get return URL or default to test page (to avoid login loop) - const returnUrl = sessionStorage.getItem('login_return_url') || '/auth/test' + // Get return URL or default to dashboard + const returnUrl = sessionStorage.getItem('login_return_url') || '/' sessionStorage.removeItem('login_return_url') - console.log('OAuth callback success, redirecting to:', returnUrl) + console.log('[OAuthCallback] โœ… Success! Redirecting to:', returnUrl) + + + // Small delay to ensure auth state propagates through React context + await new Promise(resolve => setTimeout(resolve, 100)) // Redirect to original page navigate(returnUrl, { replace: true }) diff --git a/ushadow/frontend/src/components/ShareDialog.tsx b/ushadow/frontend/src/components/ShareDialog.tsx new file mode 100644 index 00000000..b7755ee7 --- /dev/null +++ b/ushadow/frontend/src/components/ShareDialog.tsx @@ -0,0 +1,327 @@ +import React, { useState } from 'react' +import { useForm, Controller } from 'react-hook-form' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { Copy, Check, Trash2 } from 'lucide-react' +import Modal from './Modal' +import { SettingField } from './settings/SettingField' +import ConfirmDialog from './ConfirmDialog' + +interface ShareToken { + token: string + share_url: string + resource_type: string + resource_id: string + permissions: string[] + expires_at: string | null + max_views: number | null + view_count: number + require_auth: boolean + tailscale_only: boolean + created_at: string +} + +interface ShareDialogProps { + isOpen: boolean + onClose: () => void + resourceType: 'conversation' | 'memory' | 'collection' + resourceId: string +} + +interface ShareFormData { + expires_in_days: number | null + max_views: number | null + require_auth: boolean + tailscale_only: boolean + permissions: string[] +} + +const ShareDialog: React.FC = ({ + isOpen, + onClose, + resourceType, + resourceId, +}) => { + const [copiedToken, setCopiedToken] = useState(null) + const [revokeToken, setRevokeToken] = useState(null) + const queryClient = useQueryClient() + + const { control, handleSubmit, reset } = useForm({ + defaultValues: { + expires_in_days: 7, + max_views: null, + require_auth: false, + tailscale_only: false, + permissions: ['read'], + }, + }) + + // Fetch existing shares for this resource + const { data: shares, isLoading: loadingShares } = useQuery({ + queryKey: ['shares', resourceType, resourceId], + queryFn: async () => { + const response = await fetch( + `/api/share/resource/${resourceType}/${resourceId}`, + { + credentials: 'include', + } + ) + if (!response.ok) { + throw new Error('Failed to fetch shares') + } + return response.json() + }, + enabled: isOpen, + }) + + // Create share token mutation + const createShareMutation = useMutation({ + mutationFn: async (data: ShareFormData) => { + const response = await fetch('/api/share/create', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ + resource_type: resourceType, + resource_id: resourceId, + ...data, + }), + }) + if (!response.ok) { + const error = await response.json() + throw new Error(error.detail || 'Failed to create share link') + } + return response.json() + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['shares', resourceType, resourceId] }) + reset() + }, + }) + + // Revoke share token mutation + const revokeShareMutation = useMutation({ + mutationFn: async (token: string) => { + const response = await fetch(`/api/share/${token}`, { + method: 'DELETE', + credentials: 'include', + }) + if (!response.ok) { + const error = await response.json() + throw new Error(error.detail || 'Failed to revoke share link') + } + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['shares', resourceType, resourceId] }) + setRevokeToken(null) + }, + }) + + const handleCopyLink = async (shareUrl: string, token: string) => { + await navigator.clipboard.writeText(shareUrl) + setCopiedToken(token) + setTimeout(() => setCopiedToken(null), 2000) + } + + const handleCreateShare = handleSubmit(async (data) => { + await createShareMutation.mutateAsync(data) + }) + + const handleRevokeShare = async () => { + if (revokeToken) { + await revokeShareMutation.mutateAsync(revokeToken) + } + } + + return ( + <> + +
+ {/* Create new share section */} +
+

+ Create New Share Link +

+ +
+
+ ( + field.onChange(v ? Number(v) : null)} + options={[ + { value: '', label: 'Never' }, + { value: '1', label: '1 day' }, + { value: '7', label: '7 days' }, + { value: '30', label: '30 days' }, + { value: '90', label: '90 days' }, + ]} + /> + )} + /> + + ( + field.onChange(v ? Number(v) : null)} + /> + )} + /> +
+ +
+ ( + + )} + /> + + ( + + )} + /> +
+ + + + {createShareMutation.isError && ( +

+ {createShareMutation.error?.message} +

+ )} +
+
+ + {/* Existing shares section */} +
+

+ Existing Share Links +

+ + {loadingShares ? ( +

Loading shares...

+ ) : shares && shares.length > 0 ? ( +
+ {shares.map((share) => ( +
+
+
+

+ {share.share_url} +

+
+ Views: {share.view_count}{share.max_views ? `/${share.max_views}` : ''} + {share.expires_at && ( + + Expires: {new Date(share.expires_at).toLocaleDateString()} + + )} + {share.tailscale_only && Tailscale Only} +
+
+ +
+ + + +
+
+
+ ))} +
+ ) : ( +

+ No active share links. Create one above to get started. +

+ )} +
+
+
+ + setRevokeToken(null)} + onConfirm={handleRevokeShare} + title="Revoke Share Link?" + message="Anyone with this link will lose access immediately. This action cannot be undone." + variant="danger" + confirmText="Revoke Access" + testId="share-dialog-revoke-confirm" + /> + + ) +} + +export default ShareDialog diff --git a/ushadow/frontend/src/components/auth/ProtectedRoute.tsx b/ushadow/frontend/src/components/auth/ProtectedRoute.tsx index d7512954..a8d30b55 100644 --- a/ushadow/frontend/src/components/auth/ProtectedRoute.tsx +++ b/ushadow/frontend/src/components/auth/ProtectedRoute.tsx @@ -1,6 +1,7 @@ import React from 'react' import { Navigate, useLocation } from 'react-router-dom' import { useAuth } from '../../contexts/AuthContext' +import { useKeycloakAuth } from '../../contexts/KeycloakAuthContext' interface ProtectedRouteProps { children: React.ReactNode @@ -8,9 +9,13 @@ interface ProtectedRouteProps { } export default function ProtectedRoute({ children, adminOnly = false }: ProtectedRouteProps) { - const { user, token, isLoading, isAdmin, setupRequired } = useAuth() + const { user, token, isLoading: authLoading, isAdmin, setupRequired } = useAuth() + const { isAuthenticated: kcAuthenticated, isLoading: kcLoading } = useKeycloakAuth() const location = useLocation() + // Combined loading state - wait for both auth systems to check + const isLoading = authLoading || kcLoading + if (isLoading) { return (
@@ -24,7 +29,22 @@ export default function ProtectedRoute({ children, adminOnly = false }: Protecte return } - if (!token || !user) { + // Check if user is authenticated via either method: + // 1. Legacy JWT (token + user from AuthContext) + // 2. Keycloak OAuth (isAuthenticated from KeycloakAuthContext) + const isAuthenticated = (token && user) || kcAuthenticated + + console.log('[ProtectedRoute] Auth check:', { + pathname: location.pathname, + hasToken: !!token, + hasUser: !!user, + kcAuthenticated, + isAuthenticated, + willRedirect: !isAuthenticated + }) + + if (!isAuthenticated) { + console.log('[ProtectedRoute] Not authenticated, redirecting to login from:', location.pathname) // Preserve the intended destination so login can redirect back return } diff --git a/ushadow/frontend/src/components/layout/Layout.tsx b/ushadow/frontend/src/components/layout/Layout.tsx index abdf5c04..ca40147a 100644 --- a/ushadow/frontend/src/components/layout/Layout.tsx +++ b/ushadow/frontend/src/components/layout/Layout.tsx @@ -3,6 +3,7 @@ import React, { useState, useRef, useEffect } from 'react' import { Layers, MessageSquare, Plug, Bot, Workflow, Server, Settings, LogOut, Sun, Moon, Users, Search, Bell, User, ChevronDown, Brain, Home, QrCode, Calendar, Radio } from 'lucide-react' import { LayoutDashboard, Network, Flag, FlaskConical, Cloud, Mic, MicOff, Loader2, Sparkles, Zap, Archive } from 'lucide-react' import { useAuth } from '../../contexts/AuthContext' +import { useKeycloakAuth } from '../../contexts/KeycloakAuthContext' import { useTheme } from '../../contexts/ThemeContext' import { useFeatureFlags } from '../../contexts/FeatureFlagsContext' import { useWizard } from '../../contexts/WizardContext' @@ -27,7 +28,8 @@ interface NavigationItem { export default function Layout() { const location = useLocation() const navigate = useNavigate() - const { user, logout, isAdmin } = useAuth() + const { user, logout: legacyLogout, isAdmin } = useAuth() + const { isAuthenticated: kcAuthenticated, logout: kcLogout } = useKeycloakAuth() const { isDark, toggleTheme } = useTheme() const { isEnabled, flags } = useFeatureFlags() const { getSetupLabel, isFirstTimeUser } = useWizard() @@ -40,6 +42,18 @@ export default function Layout() { // QR code hook const { qrData, loading: loadingQrCode, showModal: showQrModal, fetchQrCode, closeModal } = useMobileQrCode() + // Unified logout handler that works for both auth methods + const handleLogout = () => { + if (kcAuthenticated) { + // User is authenticated via Keycloak - use Keycloak logout + kcLogout() + } else { + // User is authenticated via legacy JWT - clear localStorage only + legacyLogout() + navigate('/login') + } + } + // Get dynamic wizard label (includes path, label, level, and icon) const wizardLabel = getSetupLabel() // Helper to check if recording is in a processing state @@ -48,6 +62,20 @@ export default function Layout() { // Redirect first-time users to wizard ONLY if they just came from login/register // This prevents redirect loops when accessing the app directly useEffect(() => { + console.log('[LAYOUT] Wizard check:', { + kcAuthenticated, + pathname: location.pathname, + locationState: location.state, + isFirstTime: isFirstTimeUser(), + }) + + // Skip wizard redirect for Keycloak users - they're already authenticated via SSO + // and don't need the setup wizard + if (kcAuthenticated) { + console.log('[LAYOUT] โœ… Skipping wizard redirect - Keycloak user') + return + } + // Check sessionStorage for registration hard-reload case (cleared after reading) const sessionFromAuth = sessionStorage.getItem('fromAuth') === 'true' if (sessionFromAuth) { @@ -59,10 +87,13 @@ export default function Layout() { sessionFromAuth if (isFirstTimeUser() && fromAuth && !location.pathname.startsWith('/wizard')) { + console.log('[LAYOUT] ๐Ÿ”„ Redirecting first-time user to wizard') const { path } = getSetupLabel() navigate(path, { replace: true }) + } else { + console.log('[LAYOUT] โœ… No wizard redirect needed') } - }, [location, isFirstTimeUser, getSetupLabel, navigate]) + }, [location, isFirstTimeUser, getSetupLabel, navigate, kcAuthenticated]) // Close dropdown when clicking outside useEffect(() => { @@ -433,7 +464,7 @@ export default function Layout() { + * + * ``` + */ +export function useShare({ resourceType, resourceId }: UseShareOptions): UseShareReturn { + const [isShareDialogOpen, setIsShareDialogOpen] = useState(false) + + return { + isShareDialogOpen, + openShareDialog: () => setIsShareDialogOpen(true), + closeShareDialog: () => setIsShareDialogOpen(false), + resourceType, + resourceId, + } +} diff --git a/ushadow/frontend/src/pages/ConversationDetailPage.tsx b/ushadow/frontend/src/pages/ConversationDetailPage.tsx index 40e35544..88d872aa 100644 --- a/ushadow/frontend/src/pages/ConversationDetailPage.tsx +++ b/ushadow/frontend/src/pages/ConversationDetailPage.tsx @@ -1,6 +1,10 @@ import { useParams, useNavigate, useSearchParams } from 'react-router-dom' import { useRef, useState, useEffect } from 'react' +<<<<<<< HEAD import { ArrowLeft, MessageSquare, Clock, Calendar, User, AlertCircle, Play, Pause, Brain, ExternalLink } from 'lucide-react' +======= +import { ArrowLeft, MessageSquare, Clock, Calendar, User, AlertCircle, Play, Pause, Brain, ExternalLink, Share2 } from 'lucide-react' +>>>>>>> 0e9fc19e (feat: Add Keycloak SSO integration with conversation sharing) import { useConversationDetail } from '../hooks/useConversationDetail' import type { ConversationSource } from '../hooks/useConversations' import { useQuery } from '@tanstack/react-query' @@ -9,6 +13,11 @@ import { getChronicleAudioUrl } from '../services/chronicleApi' import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' import { MemoryCard } from '../components/memories/MemoryCard' +<<<<<<< HEAD +======= +import ShareDialog from '../components/ShareDialog' +import { useShare } from '../hooks/useShare' +>>>>>>> 0e9fc19e (feat: Add Keycloak SSO integration with conversation sharing) export default function ConversationDetailPage() { const { id } = useParams<{ id: string }>() @@ -18,6 +27,15 @@ export default function ConversationDetailPage() { const { conversation, isLoading, error } = useConversationDetail(id!, source) +<<<<<<< HEAD +======= + // Share functionality + const shareProps = useShare({ + resourceType: 'conversation', + resourceId: id || '', + }) + +>>>>>>> 0e9fc19e (feat: Add Keycloak SSO integration with conversation sharing) // Fetch memories for this conversation (unified API for both Chronicle and Mycelia) const { data: memoriesData, isLoading: memoriesLoading } = useQuery({ queryKey: ['conversation-memories', id, source], @@ -381,8 +399,13 @@ export default function ConversationDetailPage() {
+<<<<<<< HEAD {/* Play Full Audio Button */}
+======= + {/* Play Full Audio Button and Share Button */} +
+>>>>>>> 0e9fc19e (feat: Add Keycloak SSO integration with conversation sharing) +<<<<<<< HEAD
+======= + + +
+ + {/* Share Dialog */} + + +>>>>>>> 0e9fc19e (feat: Add Keycloak SSO integration with conversation sharing) {/* Metadata grid */}
{/* Start time */} diff --git a/ushadow/frontend/src/pages/LoginPage.tsx b/ushadow/frontend/src/pages/LoginPage.tsx index 4d6c1c7f..facff6e9 100644 --- a/ushadow/frontend/src/pages/LoginPage.tsx +++ b/ushadow/frontend/src/pages/LoginPage.tsx @@ -2,65 +2,32 @@ import React from 'react' import { useNavigate, useLocation } from 'react-router-dom' import { useKeycloakAuth } from '../contexts/KeycloakAuthContext' import AuthHeader from '../components/auth/AuthHeader' +import { LogIn } from 'lucide-react' export default function LoginPage() { const navigate = useNavigate() const location = useLocation() - const { isAuthenticated, isLoading, login } = useKeycloakAuth() + const { isAuthenticated, isLoading, login, register } = useKeycloakAuth() // Get the intended destination from router state (set by ProtectedRoute) const from = (location.state as { from?: string })?.from || '/' // After successful login, redirect to intended destination + // Note: Don't redirect if we're on the callback page - that's handled by OAuthCallback component React.useEffect(() => { - if (isAuthenticated) { - console.log('Login successful, redirecting to:', from) + if (isAuthenticated && location.pathname !== '/oauth/callback') { navigate(from, { replace: true, state: { fromAuth: true } }) } - }, [isAuthenticated, navigate, from]) + }, [isAuthenticated, navigate, from, location.pathname]) const handleLogin = () => { // Redirect to Keycloak login page login(from) } - const handleRegister = async () => { - // Save return URL - sessionStorage.setItem('login_return_url', from) - - // Generate CSRF state - const state = Math.random().toString(36).substring(2, 15) + - Math.random().toString(36).substring(2, 15) - sessionStorage.setItem('oauth_state', state) - - // Import TokenManager for PKCE support - const { TokenManager } = await import('../auth/TokenManager') - const keycloakConfig = { - url: import.meta.env.VITE_KEYCLOAK_URL || 'http://localhost:8081', - realm: import.meta.env.VITE_KEYCLOAK_REALM || 'ushadow', - clientId: import.meta.env.VITE_KEYCLOAK_CLIENT_ID || 'ushadow-frontend', - } - - // Build login URL with PKCE (includes code_challenge and code_challenge_method) - const loginUrl = await TokenManager.buildLoginUrl({ - keycloakUrl: keycloakConfig.url, - realm: keycloakConfig.realm, - clientId: keycloakConfig.clientId, - redirectUri: `${window.location.origin}/oauth/callback`, - state, - }) - - console.log('[REGISTER] Login URL generated:', loginUrl) - - // Keycloak registration: Add kc_action=register parameter to the auth URL - // This tells Keycloak to show the registration form instead of login - const registrationUrl = loginUrl + '&kc_action=register' - - console.log('[REGISTER] Registration URL:', registrationUrl) - console.log('[REGISTER] URL includes code_challenge_method:', registrationUrl.includes('code_challenge_method')) - - // Redirect to Keycloak registration - window.location.href = registrationUrl + const handleRegister = () => { + // Redirect to Keycloak registration page + register(from) } // Show loading while checking authentication @@ -114,145 +81,87 @@ export default function LoginPage() { />
-
- +
+ - {/* Login Form Card */} + {/* Login Card */}
-
{ e.preventDefault(); handleLogin(); }}> - {/* Email Field */} -
- - -
- - {/* Password Field */} -
- -
- - -
-
- - {/* Remember me and Forgot password */} - - - {/* Sign In Button */} - -
+
+

+ Welcome to Ushadow +

+

+ Secure authentication powered by Keycloak +

+
- {/* Register Link */} -
- New user? - + + Sign in with Keycloak + + +
+

+ You'll be redirected to Keycloak for secure authentication +

+
+ + {/* Divider */} +
+
+
+
+
+ + Or + +
+
+ +
+

+ Don't have an account?{' '} + +

+ + {/* Info Card */} +
+

+ New to Ushadow? Your administrator will provide you with access credentials. +

+
diff --git a/ushadow/frontend/src/services/api.ts b/ushadow/frontend/src/services/api.ts index ccd4df1e..4c0ded2f 100644 --- a/ushadow/frontend/src/services/api.ts +++ b/ushadow/frontend/src/services/api.ts @@ -62,7 +62,15 @@ export const api = axios.create({ // Add request interceptor to include auth token api.interceptors.request.use((config) => { - const token = localStorage.getItem(getStorageKey('token')) + // Check for Keycloak token first (in sessionStorage) + const kcToken = sessionStorage.getItem('kc_access_token') + + // Fallback to legacy JWT token (in localStorage) + const legacyToken = localStorage.getItem(getStorageKey('token')) + + // Prefer Keycloak token if both are present + const token = kcToken || legacyToken + if (token) { config.headers.Authorization = `Bearer ${token}` } diff --git a/ushadow/frontend/src/wizards/QuickstartWizard.tsx b/ushadow/frontend/src/wizards/QuickstartWizard.tsx index 0e297358..0d18d0a1 100644 --- a/ushadow/frontend/src/wizards/QuickstartWizard.tsx +++ b/ushadow/frontend/src/wizards/QuickstartWizard.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react' import { useNavigate } from 'react-router-dom' -import { Sparkles, Loader2, RefreshCw } from 'lucide-react' +import { Sparkles, Loader2, RefreshCw, CheckCircle } from 'lucide-react' import { servicesApi, quickstartApi, type QuickstartConfig, type CapabilityRequirement, type ServiceInfo } from '../services/api' import { ServiceStatusCard, type ServiceStatus } from '../components/services'