feat: Add OIDC authentication with Authentik integration
- Add OIDC login flow with Authentik provider - Implement session-based auth with Redis store - Add avatar display from OIDC claims - Fix input field performance with react-textarea-autosize - Stabilize callbacks to prevent unnecessary re-renders - Fix history loading to skip empty session files - Add 2-row default height for input textarea 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
3
.env.example
Normal file
3
.env.example
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Netbird Setup Key from https://gw.sneakercloud.de
|
||||||
|
# Create a new setup key for "claude-webui" with appropriate permissions
|
||||||
|
NETBIRD_SETUP_KEY=your-setup-key-here
|
||||||
402
.prompts/001-oidc-auth-plan/SUMMARY.md
Normal file
402
.prompts/001-oidc-auth-plan/SUMMARY.md
Normal file
@@ -0,0 +1,402 @@
|
|||||||
|
# OIDC Authentication Implementation - Executive Summary
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This plan details the complete implementation of OIDC authentication for Claude Web UI using Authentik as the identity provider. The implementation adds secure, production-ready authentication with group-based access control while preserving the existing WebSocket architecture.
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
**Backend:**
|
||||||
|
- Single-file Express server (`backend/server.js`, 999 lines)
|
||||||
|
- WebSocket-based Claude Code CLI interface
|
||||||
|
- No authentication or authorization
|
||||||
|
- In-memory session storage
|
||||||
|
|
||||||
|
**Frontend:**
|
||||||
|
- React application with SessionContext for state management
|
||||||
|
- Direct WebSocket connections
|
||||||
|
- localStorage for session persistence
|
||||||
|
- No authentication UI
|
||||||
|
|
||||||
|
## Target State
|
||||||
|
|
||||||
|
**Authentication:**
|
||||||
|
- OIDC provider: Authentik (auth.sneakercloud.de)
|
||||||
|
- Authorization flow: Authorization Code with PKCE
|
||||||
|
- Token storage: httpOnly cookies (XSS-safe)
|
||||||
|
- Session backend: Redis (persistent, scalable)
|
||||||
|
|
||||||
|
**Authorization:**
|
||||||
|
- Groups: `agent-admin` (full access), `agent-users` (standard access)
|
||||||
|
- Protected REST endpoints
|
||||||
|
- Authenticated WebSocket connections
|
||||||
|
- User-isolated sessions
|
||||||
|
|
||||||
|
## Implementation Approach
|
||||||
|
|
||||||
|
### 5 Phases, 11-16 Days
|
||||||
|
|
||||||
|
#### Phase 1: Foundation (2-3 days)
|
||||||
|
- Configure Authentik OIDC Provider/Application
|
||||||
|
- Refactor monolithic backend into modules
|
||||||
|
- Add dependencies (express-session, openid-client, Redis)
|
||||||
|
- Implement Redis session store
|
||||||
|
|
||||||
|
#### Phase 2: Authentication Flow (2-3 days)
|
||||||
|
- Implement OIDC client wrapper
|
||||||
|
- Create auth routes (login, callback, logout)
|
||||||
|
- Configure secure session cookies
|
||||||
|
- Handle token refresh
|
||||||
|
|
||||||
|
#### Phase 3: API Protection (2-3 days)
|
||||||
|
- Create authentication middleware
|
||||||
|
- Protect all REST endpoints
|
||||||
|
- Secure WebSocket connections
|
||||||
|
- Associate sessions with users
|
||||||
|
|
||||||
|
#### Phase 4: Frontend UI (3-4 days)
|
||||||
|
- Create AuthContext for authentication state
|
||||||
|
- Build login page component
|
||||||
|
- Add protected route wrapper
|
||||||
|
- Integrate with existing SessionContext
|
||||||
|
- Add user menu/logout
|
||||||
|
|
||||||
|
#### Phase 5: Production Hardening (2-3 days)
|
||||||
|
- Security enhancements (CSRF, rate limiting, CSP)
|
||||||
|
- Logging and monitoring
|
||||||
|
- Error handling and edge cases
|
||||||
|
- Documentation
|
||||||
|
- Testing
|
||||||
|
|
||||||
|
## Key Technical Decisions
|
||||||
|
|
||||||
|
### 1. Backend Refactoring
|
||||||
|
**Decision:** Split monolithic `server.js` into modular structure
|
||||||
|
|
||||||
|
**Rationale:**
|
||||||
|
- Improves maintainability
|
||||||
|
- Separates concerns
|
||||||
|
- Easier testing
|
||||||
|
- Supports future growth
|
||||||
|
|
||||||
|
**Structure:**
|
||||||
|
```
|
||||||
|
backend/
|
||||||
|
├── server.js # Entry point
|
||||||
|
├── config/auth.js # Auth configuration
|
||||||
|
├── middleware/ # Auth & session middleware
|
||||||
|
├── routes/ # API, auth, WebSocket routes
|
||||||
|
└── utils/ # OIDC client wrapper
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Session Storage
|
||||||
|
**Decision:** Redis with express-session
|
||||||
|
|
||||||
|
**Rationale:**
|
||||||
|
- Persistent across restarts
|
||||||
|
- Scalable to multiple backend instances
|
||||||
|
- Fast session lookups (<10ms)
|
||||||
|
- Industry standard
|
||||||
|
|
||||||
|
**Alternative Considered:** In-memory sessions
|
||||||
|
- **Rejected:** Lost on restart, not scalable
|
||||||
|
|
||||||
|
### 3. Token Storage
|
||||||
|
**Decision:** httpOnly cookies with encrypted tokens in Redis
|
||||||
|
|
||||||
|
**Rationale:**
|
||||||
|
- httpOnly prevents XSS attacks
|
||||||
|
- Secure flag ensures HTTPS-only
|
||||||
|
- sameSite=lax prevents CSRF
|
||||||
|
- Encryption at rest protects Redis compromise
|
||||||
|
|
||||||
|
**Alternative Considered:** localStorage
|
||||||
|
- **Rejected:** Vulnerable to XSS
|
||||||
|
|
||||||
|
### 4. WebSocket Authentication
|
||||||
|
**Decision:** Cookie-based session validation on upgrade
|
||||||
|
|
||||||
|
**Rationale:**
|
||||||
|
- Cookies automatically included in upgrade request
|
||||||
|
- Consistent with REST API auth
|
||||||
|
- No need for separate token mechanism
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
- Parse cookies from upgrade request headers
|
||||||
|
- Load session from Redis
|
||||||
|
- Validate user before accepting connection
|
||||||
|
- Reject upgrade with 1008 code if unauthorized
|
||||||
|
|
||||||
|
### 5. OIDC Library
|
||||||
|
**Decision:** `openid-client` (certified OIDC client)
|
||||||
|
|
||||||
|
**Rationale:**
|
||||||
|
- Official OpenID Foundation certified
|
||||||
|
- Actively maintained
|
||||||
|
- Built-in PKCE support
|
||||||
|
- Automatic discovery
|
||||||
|
|
||||||
|
**Alternative Considered:** `passport-openidconnect`
|
||||||
|
- **Rejected:** More complex, passport overhead not needed
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
- Authorization Code flow with PKCE
|
||||||
|
- State parameter for CSRF protection
|
||||||
|
- Nonce validation in ID token
|
||||||
|
- Token signature verification
|
||||||
|
- HTTPS-only in production
|
||||||
|
|
||||||
|
### Session Management
|
||||||
|
- httpOnly cookies (no JavaScript access)
|
||||||
|
- Secure flag (HTTPS-only)
|
||||||
|
- sameSite=lax (CSRF protection)
|
||||||
|
- 24-hour expiry with refresh
|
||||||
|
- Encrypted tokens at rest
|
||||||
|
|
||||||
|
### API Protection
|
||||||
|
- All endpoints require authentication
|
||||||
|
- Group-based authorization
|
||||||
|
- Rate limiting on auth endpoints
|
||||||
|
- Origin validation on WebSocket upgrade
|
||||||
|
- Content Security Policy headers
|
||||||
|
|
||||||
|
### Token Handling
|
||||||
|
- Access tokens encrypted in Redis
|
||||||
|
- Refresh tokens encrypted in Redis
|
||||||
|
- Auto-refresh 5 minutes before expiry
|
||||||
|
- Tokens cleared on logout
|
||||||
|
- No tokens in logs
|
||||||
|
|
||||||
|
## Group-Based Access Control
|
||||||
|
|
||||||
|
### agent-users (Standard Access)
|
||||||
|
- View hosts and projects
|
||||||
|
- Create and manage own Claude sessions
|
||||||
|
- Upload files to own sessions
|
||||||
|
- View own session history
|
||||||
|
- Standard WebSocket access
|
||||||
|
|
||||||
|
### agent-admin (Full Access)
|
||||||
|
- All `agent-users` permissions
|
||||||
|
- View all users' sessions (future)
|
||||||
|
- Access admin endpoints (future)
|
||||||
|
- Modify system configuration (future)
|
||||||
|
|
||||||
|
**Note:** Initial implementation focuses on authentication and basic group enforcement. Fine-grained permissions can be expanded post-MVP.
|
||||||
|
|
||||||
|
## Integration Points
|
||||||
|
|
||||||
|
### Authentik Configuration
|
||||||
|
```yaml
|
||||||
|
Provider:
|
||||||
|
Name: Claude Web UI
|
||||||
|
Type: OAuth2/OIDC
|
||||||
|
Client Type: Confidential
|
||||||
|
Flow: Authorization Code
|
||||||
|
Scopes: openid, profile, email, groups
|
||||||
|
|
||||||
|
Application:
|
||||||
|
Name: Claude Web UI
|
||||||
|
Provider: [linked]
|
||||||
|
Launch URL: https://agents.sneakercloud.de
|
||||||
|
Redirect URI: https://agents.sneakercloud.de/auth/callback
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
```bash
|
||||||
|
# OIDC
|
||||||
|
OIDC_ISSUER=https://auth.sneakercloud.de/application/o/claude-web-ui/
|
||||||
|
OIDC_CLIENT_ID=<from_authentik>
|
||||||
|
OIDC_CLIENT_SECRET=<from_authentik>
|
||||||
|
OIDC_REDIRECT_URI=https://agents.sneakercloud.de/auth/callback
|
||||||
|
|
||||||
|
# Session
|
||||||
|
SESSION_SECRET=<generate_32_bytes>
|
||||||
|
SESSION_MAX_AGE=86400000 # 24 hours
|
||||||
|
REDIS_URL=redis://redis:6379
|
||||||
|
|
||||||
|
# App
|
||||||
|
NODE_ENV=production
|
||||||
|
FRONTEND_URL=https://agents.sneakercloud.de
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker Compose Updates
|
||||||
|
- Add Redis service
|
||||||
|
- Mount Redis volume for persistence
|
||||||
|
- Add environment variables
|
||||||
|
- Configure service dependencies
|
||||||
|
|
||||||
|
## Risk Assessment
|
||||||
|
|
||||||
|
### High Risk
|
||||||
|
**WebSocket Authentication Complexity**
|
||||||
|
- Mitigation: Thorough testing, fallback to polling
|
||||||
|
- Contingency: Feature flag to disable auth temporarily
|
||||||
|
|
||||||
|
**Authentik Downtime**
|
||||||
|
- Mitigation: Session caching, graceful degradation
|
||||||
|
- Contingency: Allow continued use of valid sessions
|
||||||
|
|
||||||
|
**Session Store Failure (Redis)**
|
||||||
|
- Mitigation: Redis persistence, backup strategy
|
||||||
|
- Contingency: Fallback to in-memory (degraded mode)
|
||||||
|
|
||||||
|
### Medium Risk
|
||||||
|
**Token Refresh Failures**
|
||||||
|
- Mitigation: Retry logic, clear error messages
|
||||||
|
- Contingency: Force re-login
|
||||||
|
|
||||||
|
**CORS/Cookie Issues**
|
||||||
|
- Mitigation: Proper domain configuration, testing
|
||||||
|
- Contingency: Documented troubleshooting steps
|
||||||
|
|
||||||
|
### Low Risk
|
||||||
|
**Group Mapping Errors**
|
||||||
|
- Mitigation: Default to `agent-users` if no groups
|
||||||
|
- Contingency: Manual group assignment in Authentik
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
### Unit Tests
|
||||||
|
- OIDC client wrapper
|
||||||
|
- Authentication middleware
|
||||||
|
- Session management functions
|
||||||
|
|
||||||
|
### Integration Tests
|
||||||
|
- Full login flow
|
||||||
|
- Token exchange
|
||||||
|
- Session persistence
|
||||||
|
- WebSocket authentication
|
||||||
|
|
||||||
|
### Manual Tests
|
||||||
|
- Login/logout flow
|
||||||
|
- Session refresh
|
||||||
|
- Token expiry handling
|
||||||
|
- Group-based access
|
||||||
|
- Multiple concurrent users
|
||||||
|
- Admin vs user permissions
|
||||||
|
|
||||||
|
### Security Tests
|
||||||
|
- XSS prevention (httpOnly cookies)
|
||||||
|
- CSRF protection
|
||||||
|
- Unauthorized API access
|
||||||
|
- Session hijacking attempts
|
||||||
|
- Token replay attacks
|
||||||
|
|
||||||
|
## Rollback Plan
|
||||||
|
|
||||||
|
**Feature Flag:** `AUTH_ENABLED` environment variable
|
||||||
|
|
||||||
|
**Rollback Steps:**
|
||||||
|
1. Set `AUTH_ENABLED=false`
|
||||||
|
2. Restart backend service
|
||||||
|
3. Authentication middleware skipped
|
||||||
|
4. Frontend shows app without login
|
||||||
|
5. Investigate and fix issues
|
||||||
|
6. Re-enable with `AUTH_ENABLED=true`
|
||||||
|
|
||||||
|
**Data Preservation:**
|
||||||
|
- Sessions stored in Redis persist
|
||||||
|
- User data not lost
|
||||||
|
- Can re-enable seamlessly
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
### Functional
|
||||||
|
- [ ] Unauthenticated users cannot access app
|
||||||
|
- [ ] Login redirects to Authentik and back successfully
|
||||||
|
- [ ] WebSocket connections authenticated
|
||||||
|
- [ ] Sessions persist across browser refresh
|
||||||
|
- [ ] Logout clears session completely
|
||||||
|
- [ ] Group-based access control enforced
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
- [ ] Auth middleware overhead <100ms
|
||||||
|
- [ ] Session lookup <10ms
|
||||||
|
- [ ] No WebSocket latency impact
|
||||||
|
- [ ] Token refresh transparent to user
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- [ ] Zero XSS vulnerabilities
|
||||||
|
- [ ] Zero unauthorized access
|
||||||
|
- [ ] Tokens encrypted at rest
|
||||||
|
- [ ] HTTPS-only in production
|
||||||
|
- [ ] Rate limiting effective
|
||||||
|
|
||||||
|
### User Experience
|
||||||
|
- [ ] Login flow <5 seconds
|
||||||
|
- [ ] Clear error messages
|
||||||
|
- [ ] No unnecessary re-authentication
|
||||||
|
- [ ] Seamless session refresh
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
**Post-MVP Features:**
|
||||||
|
- Multi-factor authentication via Authentik
|
||||||
|
- API keys for CLI/programmatic access
|
||||||
|
- Session management UI (view/revoke sessions)
|
||||||
|
- Audit log for all actions
|
||||||
|
- Fine-grained RBAC expansion
|
||||||
|
- User preferences/settings storage
|
||||||
|
- SSO with other internal services
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
### NPM Packages
|
||||||
|
- `express-session@^1.18.0` - Session management
|
||||||
|
- `connect-redis@^7.1.0` - Redis session store
|
||||||
|
- `redis@^4.6.0` - Redis client
|
||||||
|
- `openid-client@^5.6.0` - OIDC client
|
||||||
|
- `cookie-parser@^1.4.6` - Cookie parsing
|
||||||
|
|
||||||
|
### Infrastructure
|
||||||
|
- Redis (session storage)
|
||||||
|
- Authentik (identity provider)
|
||||||
|
- Docker (containerization)
|
||||||
|
|
||||||
|
### External Services
|
||||||
|
- Authentik @ auth.sneakercloud.de
|
||||||
|
- Redis instance (new or existing)
|
||||||
|
|
||||||
|
## Documentation Deliverables
|
||||||
|
|
||||||
|
1. **AUTHENTICATION.md** - System overview and architecture
|
||||||
|
2. **SETUP.md** - Step-by-step Authentik configuration
|
||||||
|
3. **CONFIGURATION.md** - Environment variables reference
|
||||||
|
4. **TROUBLESHOOTING.md** - Common issues and solutions
|
||||||
|
5. **README.md** - Updated with authentication section
|
||||||
|
|
||||||
|
## Timeline Summary
|
||||||
|
|
||||||
|
| Phase | Duration | Key Deliverables |
|
||||||
|
|-------|----------|------------------|
|
||||||
|
| 1: Foundation | 2-3 days | Authentik setup, backend refactor, Redis |
|
||||||
|
| 2: Auth Flow | 2-3 days | OIDC routes, token handling, callbacks |
|
||||||
|
| 3: API Protection | 2-3 days | Middleware, protected endpoints, WebSocket auth |
|
||||||
|
| 4: Frontend UI | 3-4 days | AuthContext, login page, user menu |
|
||||||
|
| 5: Hardening | 2-3 days | Security, logging, testing, docs |
|
||||||
|
| **Total** | **11-16 days** | Production-ready OIDC authentication |
|
||||||
|
|
||||||
|
## Approval & Next Steps
|
||||||
|
|
||||||
|
**This Plan:**
|
||||||
|
- Provides complete roadmap for OIDC implementation
|
||||||
|
- Addresses all security requirements
|
||||||
|
- Maintains existing functionality
|
||||||
|
- Enables phased rollout
|
||||||
|
- Includes rollback strategy
|
||||||
|
|
||||||
|
**Next Steps:**
|
||||||
|
1. Review and approve plan
|
||||||
|
2. Set up Authentik provider/application
|
||||||
|
3. Begin Phase 1 implementation
|
||||||
|
4. Schedule checkpoints after each phase
|
||||||
|
5. Plan production deployment
|
||||||
|
|
||||||
|
**Questions/Concerns:**
|
||||||
|
- Authentik already configured or needs setup?
|
||||||
|
- Redis instance available or needs provisioning?
|
||||||
|
- Preferred timeline/priority adjustments?
|
||||||
|
- Additional requirements or constraints?
|
||||||
253
.prompts/001-oidc-auth-plan/completed/001-oidc-auth-plan.md
Normal file
253
.prompts/001-oidc-auth-plan/completed/001-oidc-auth-plan.md
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
<objective>
|
||||||
|
Create an implementation roadmap for OIDC authentication with Authentik for Claude Web UI.
|
||||||
|
|
||||||
|
Purpose: Guide phased implementation of OIDC authentication with clear milestones
|
||||||
|
Input: Current codebase structure, Authentik configuration requirements
|
||||||
|
Output: oidc-auth-plan.md with 4-5 implementation phases
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
Project: Claude Web UI - Web interface for Claude Code CLI
|
||||||
|
URL: https://agents.sneakercloud.de
|
||||||
|
Stack:
|
||||||
|
- Backend: Node.js/Express (server.js) with WebSocket
|
||||||
|
- Frontend: React/Vite with JSX components
|
||||||
|
- Auth Provider: Authentik (https://auth.sneakercloud.de)
|
||||||
|
|
||||||
|
Current backend structure:
|
||||||
|
- Single file: backend/server.js (~999 lines)
|
||||||
|
- Express app with CORS, WebSocket server
|
||||||
|
- No authentication currently implemented
|
||||||
|
- REST endpoints: /api/hosts, /api/projects, /api/health, /api/browse, /api/upload, /api/history
|
||||||
|
|
||||||
|
Current frontend structure:
|
||||||
|
- src/App.jsx - Main application
|
||||||
|
- src/contexts/SessionContext.jsx - Session state management
|
||||||
|
- src/hooks/useClaudeSession.js - WebSocket connection hook
|
||||||
|
- src/components/ - UI components (ChatPanel, Sidebar, Header, etc.)
|
||||||
|
|
||||||
|
Authentication requirements:
|
||||||
|
- OIDC Provider: Authentik
|
||||||
|
- Auth Flow: Redirect Flow (Authorization Code)
|
||||||
|
- Token Storage: httpOnly Cookies (XSS-safe)
|
||||||
|
- Groups: agent-admin (full access), agent-users (standard access)
|
||||||
|
- Session Duration: Configurable, with refresh token support
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<planning_requirements>
|
||||||
|
Requirements to address:
|
||||||
|
1. Authentik OIDC Application setup (Provider + Application)
|
||||||
|
2. Backend OIDC middleware with session management
|
||||||
|
3. Frontend AuthContext with login/logout flow
|
||||||
|
4. Protected routes and API endpoints
|
||||||
|
5. Group-based access control (admin vs users)
|
||||||
|
6. httpOnly cookie-based token storage
|
||||||
|
|
||||||
|
Constraints:
|
||||||
|
- Must work with existing WebSocket architecture
|
||||||
|
- Minimal changes to existing components
|
||||||
|
- Single-file backend (server.js) - consider refactoring into modules
|
||||||
|
- Production-ready security (no localStorage tokens)
|
||||||
|
|
||||||
|
Success criteria:
|
||||||
|
- Unauthenticated users see login page
|
||||||
|
- Login redirects to Authentik, returns with session
|
||||||
|
- WebSocket connections are authenticated
|
||||||
|
- API endpoints check authentication
|
||||||
|
- Admin users have additional capabilities
|
||||||
|
- Sessions persist across browser refresh
|
||||||
|
</planning_requirements>
|
||||||
|
|
||||||
|
<output_structure>
|
||||||
|
Save to: `.prompts/001-oidc-auth-plan/oidc-auth-plan.md`
|
||||||
|
|
||||||
|
Structure the plan using this XML format:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<plan>
|
||||||
|
<summary>
|
||||||
|
{One paragraph overview of the OIDC implementation approach}
|
||||||
|
</summary>
|
||||||
|
|
||||||
|
<phases>
|
||||||
|
<phase number="1" name="authentik-setup">
|
||||||
|
<objective>Create OIDC Provider and Application in Authentik</objective>
|
||||||
|
<tasks>
|
||||||
|
<task priority="high">Create OAuth2/OIDC Provider in Authentik</task>
|
||||||
|
<task priority="high">Configure redirect URIs for agents.sneakercloud.de</task>
|
||||||
|
<task priority="high">Create Application and bind to Provider</task>
|
||||||
|
<task priority="medium">Create groups: agent-admin, agent-users</task>
|
||||||
|
<task priority="medium">Configure group claims in OIDC scope</task>
|
||||||
|
</tasks>
|
||||||
|
<deliverables>
|
||||||
|
<deliverable>Authentik OIDC app configured</deliverable>
|
||||||
|
<deliverable>Client ID and Secret generated</deliverable>
|
||||||
|
<deliverable>Groups created and mapped</deliverable>
|
||||||
|
</deliverables>
|
||||||
|
<dependencies>Admin access to Authentik</dependencies>
|
||||||
|
<execution_notes>
|
||||||
|
Document the exact Authentik configuration steps.
|
||||||
|
Save Client ID/Secret to .env (not committed).
|
||||||
|
Test OIDC endpoints manually before proceeding.
|
||||||
|
</execution_notes>
|
||||||
|
</phase>
|
||||||
|
|
||||||
|
<phase number="2" name="backend-auth-module">
|
||||||
|
<objective>Implement OIDC authentication in backend</objective>
|
||||||
|
<tasks>
|
||||||
|
<task priority="high">Install dependencies: openid-client, cookie-parser, express-session</task>
|
||||||
|
<task priority="high">Create auth module with OIDC client configuration</task>
|
||||||
|
<task priority="high">Implement /api/auth/login - redirect to Authentik</task>
|
||||||
|
<task priority="high">Implement /api/auth/callback - handle OIDC response</task>
|
||||||
|
<task priority="high">Implement /api/auth/logout - clear session</task>
|
||||||
|
<task priority="medium">Implement /api/auth/me - return current user info</task>
|
||||||
|
<task priority="medium">Create auth middleware for protected routes</task>
|
||||||
|
<task priority="medium">Add session validation to WebSocket connections</task>
|
||||||
|
</tasks>
|
||||||
|
<deliverables>
|
||||||
|
<deliverable>backend/auth.js module</deliverable>
|
||||||
|
<deliverable>Session-based authentication with httpOnly cookies</deliverable>
|
||||||
|
<deliverable>Protected API routes</deliverable>
|
||||||
|
</deliverables>
|
||||||
|
<dependencies>Phase 1 complete (Authentik configured)</dependencies>
|
||||||
|
<execution_notes>
|
||||||
|
Use openid-client for OIDC implementation.
|
||||||
|
Store session in memory initially (can add Redis later).
|
||||||
|
Validate ID tokens and extract user info.
|
||||||
|
Pass user context to WebSocket sessions.
|
||||||
|
</execution_notes>
|
||||||
|
</phase>
|
||||||
|
|
||||||
|
<phase number="3" name="frontend-auth-context">
|
||||||
|
<objective>Implement React authentication context and UI</objective>
|
||||||
|
<tasks>
|
||||||
|
<task priority="high">Create AuthContext with user state management</task>
|
||||||
|
<task priority="high">Create useAuth hook for components</task>
|
||||||
|
<task priority="high">Create LoginPage component</task>
|
||||||
|
<task priority="high">Implement protected route wrapper</task>
|
||||||
|
<task priority="medium">Add login/logout buttons to Header</task>
|
||||||
|
<task priority="medium">Show user info in UI</task>
|
||||||
|
<task priority="low">Add loading state during auth check</task>
|
||||||
|
</tasks>
|
||||||
|
<deliverables>
|
||||||
|
<deliverable>src/contexts/AuthContext.jsx</deliverable>
|
||||||
|
<deliverable>src/hooks/useAuth.js</deliverable>
|
||||||
|
<deliverable>src/components/LoginPage.jsx</deliverable>
|
||||||
|
<deliverable>src/components/ProtectedRoute.jsx</deliverable>
|
||||||
|
</deliverables>
|
||||||
|
<dependencies>Phase 2 complete (backend auth working)</dependencies>
|
||||||
|
<execution_notes>
|
||||||
|
Check /api/auth/me on app load to determine auth state.
|
||||||
|
Redirect to login page if not authenticated.
|
||||||
|
After login, redirect back to original URL.
|
||||||
|
Handle auth errors gracefully.
|
||||||
|
</execution_notes>
|
||||||
|
</phase>
|
||||||
|
|
||||||
|
<phase number="4" name="group-permissions">
|
||||||
|
<objective>Implement group-based access control</objective>
|
||||||
|
<tasks>
|
||||||
|
<task priority="high">Extract groups from OIDC claims in backend</task>
|
||||||
|
<task priority="high">Add isAdmin flag to user context</task>
|
||||||
|
<task priority="medium">Protect admin-only API routes</task>
|
||||||
|
<task priority="medium">Show admin UI elements conditionally</task>
|
||||||
|
<task priority="low">Add user management UI for admins (future)</task>
|
||||||
|
</tasks>
|
||||||
|
<deliverables>
|
||||||
|
<deliverable>Group-based route protection</deliverable>
|
||||||
|
<deliverable>Admin-specific UI elements</deliverable>
|
||||||
|
</deliverables>
|
||||||
|
<dependencies>Phase 3 complete</dependencies>
|
||||||
|
<execution_notes>
|
||||||
|
agent-admin: Full access, all hosts, all features
|
||||||
|
agent-users: Limited hosts, standard features
|
||||||
|
Check group membership on backend, pass to frontend.
|
||||||
|
</execution_notes>
|
||||||
|
</phase>
|
||||||
|
|
||||||
|
<phase number="5" name="testing-polish">
|
||||||
|
<objective>Test, document, and polish the implementation</objective>
|
||||||
|
<tasks>
|
||||||
|
<task priority="high">Test full auth flow end-to-end</task>
|
||||||
|
<task priority="high">Test WebSocket reconnection after session refresh</task>
|
||||||
|
<task priority="medium">Handle edge cases (expired sessions, revoked tokens)</task>
|
||||||
|
<task priority="medium">Update BookStack documentation</task>
|
||||||
|
<task priority="low">Add session timeout warnings</task>
|
||||||
|
</tasks>
|
||||||
|
<deliverables>
|
||||||
|
<deliverable>Working OIDC authentication</deliverable>
|
||||||
|
<deliverable>Updated documentation</deliverable>
|
||||||
|
</deliverables>
|
||||||
|
<dependencies>Phase 4 complete</dependencies>
|
||||||
|
</phase>
|
||||||
|
</phases>
|
||||||
|
|
||||||
|
<metadata>
|
||||||
|
<confidence level="high">
|
||||||
|
OIDC with Authentik is well-documented pattern.
|
||||||
|
httpOnly cookies with redirect flow is industry standard.
|
||||||
|
openid-client is mature library for Node.js.
|
||||||
|
</confidence>
|
||||||
|
<dependencies>
|
||||||
|
- Authentik admin access
|
||||||
|
- DNS/SSL already configured (agents.sneakercloud.de)
|
||||||
|
- Current app deployed and working
|
||||||
|
</dependencies>
|
||||||
|
<open_questions>
|
||||||
|
- Session storage: In-memory vs Redis?
|
||||||
|
- Token refresh strategy: Silent refresh vs re-login?
|
||||||
|
- Should WebSocket auth use same session or separate token?
|
||||||
|
</open_questions>
|
||||||
|
<assumptions>
|
||||||
|
- Authentik is accessible at auth.sneakercloud.de
|
||||||
|
- User has admin access to create OIDC app
|
||||||
|
- Current backend can be extended (single server.js file)
|
||||||
|
- Frontend can add new components without major refactoring
|
||||||
|
</assumptions>
|
||||||
|
</metadata>
|
||||||
|
</plan>
|
||||||
|
```
|
||||||
|
</output_structure>
|
||||||
|
|
||||||
|
<summary_requirements>
|
||||||
|
Create `.prompts/001-oidc-auth-plan/SUMMARY.md`
|
||||||
|
|
||||||
|
Structure:
|
||||||
|
```markdown
|
||||||
|
# OIDC Auth Plan Summary
|
||||||
|
|
||||||
|
**{Substantive one-liner describing the plan}**
|
||||||
|
|
||||||
|
## Version
|
||||||
|
v1
|
||||||
|
|
||||||
|
## Key Findings
|
||||||
|
- {Phase 1 objective}
|
||||||
|
- {Phase 2 objective}
|
||||||
|
- {Phase 3 objective}
|
||||||
|
- {Key architectural decision}
|
||||||
|
|
||||||
|
## Decisions Needed
|
||||||
|
{Specific decisions requiring user input}
|
||||||
|
|
||||||
|
## Blockers
|
||||||
|
{External impediments, or "None"}
|
||||||
|
|
||||||
|
## Next Step
|
||||||
|
{Concrete forward action - likely "Execute Phase 1"}
|
||||||
|
|
||||||
|
---
|
||||||
|
*Confidence: High*
|
||||||
|
*Full output: oidc-auth-plan.md*
|
||||||
|
```
|
||||||
|
</summary_requirements>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- Plan addresses all 5 requirements (Authentik, backend, frontend, routes, groups)
|
||||||
|
- Phases are sequential and logically ordered
|
||||||
|
- Each phase builds on previous deliverables
|
||||||
|
- Tasks are specific and actionable
|
||||||
|
- Metadata captures open questions and assumptions
|
||||||
|
- SUMMARY.md created with phase overview
|
||||||
|
- Ready for implementation prompts to consume
|
||||||
|
</success_criteria>
|
||||||
766
.prompts/001-oidc-auth-plan/oidc-auth-plan.md
Normal file
766
.prompts/001-oidc-auth-plan/oidc-auth-plan.md
Normal file
@@ -0,0 +1,766 @@
|
|||||||
|
# OIDC Authentication Implementation Plan for Claude Web UI
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
**Project:** Claude Web UI - Web interface for Claude Code CLI
|
||||||
|
**URL:** https://agents.sneakercloud.de
|
||||||
|
**Auth Provider:** Authentik (https://auth.sneakercloud.de)
|
||||||
|
**Implementation Goal:** Add production-ready OIDC authentication with group-based access control
|
||||||
|
|
||||||
|
## Current Architecture Analysis
|
||||||
|
|
||||||
|
### Backend (Node.js/Express)
|
||||||
|
- **File:** `backend/server.js` (999 lines)
|
||||||
|
- **Framework:** Express with WebSocket (ws library)
|
||||||
|
- **Current State:** No authentication
|
||||||
|
- **Endpoints:**
|
||||||
|
- REST: `/api/hosts`, `/api/projects`, `/api/health`, `/api/browse`, `/api/upload/:sessionId`, `/api/history/:project`
|
||||||
|
- WebSocket: Single endpoint on root path
|
||||||
|
- **Sessions:** In-memory Map with UUIDs, no user association
|
||||||
|
|
||||||
|
### Frontend (React/Vite)
|
||||||
|
- **Main:** `frontend/src/App.jsx` - Application shell
|
||||||
|
- **State:** `frontend/src/contexts/SessionContext.jsx` - Session management via React Context
|
||||||
|
- **WebSocket:** Direct connections stored in refs
|
||||||
|
- **Storage:** localStorage for session persistence
|
||||||
|
- **Current State:** No authentication UI or logic
|
||||||
|
|
||||||
|
### Key Constraints
|
||||||
|
1. Single-file backend (consider modular refactoring)
|
||||||
|
2. WebSocket architecture must be preserved
|
||||||
|
3. No localStorage for tokens (XSS risk)
|
||||||
|
4. Production security requirements
|
||||||
|
5. Group-based access control needed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Authentik Configuration & Backend Foundation
|
||||||
|
|
||||||
|
### Objectives
|
||||||
|
- Configure Authentik OIDC Provider and Application
|
||||||
|
- Refactor backend into modular structure
|
||||||
|
- Add core authentication dependencies
|
||||||
|
- Implement session storage mechanism
|
||||||
|
|
||||||
|
### Tasks
|
||||||
|
|
||||||
|
#### 1.1 Authentik Setup
|
||||||
|
**Provider Configuration:**
|
||||||
|
```yaml
|
||||||
|
Name: Claude Web UI
|
||||||
|
Client Type: Confidential
|
||||||
|
Authorization Flow: Authorization Code
|
||||||
|
Redirect URIs:
|
||||||
|
- https://agents.sneakercloud.de/auth/callback
|
||||||
|
- http://localhost:5173/auth/callback (dev)
|
||||||
|
Scopes: openid, profile, email, groups
|
||||||
|
```
|
||||||
|
|
||||||
|
**Application Configuration:**
|
||||||
|
```yaml
|
||||||
|
Name: Claude Web UI
|
||||||
|
Provider: [linked to above]
|
||||||
|
Launch URL: https://agents.sneakercloud.de
|
||||||
|
```
|
||||||
|
|
||||||
|
**Group Mappings:**
|
||||||
|
- Create groups: `agent-admin`, `agent-users`
|
||||||
|
- Configure scope mappings to include groups in ID token
|
||||||
|
- Test with user accounts
|
||||||
|
|
||||||
|
**Deliverables:**
|
||||||
|
- Client ID and Client Secret
|
||||||
|
- Discovery URL: `https://auth.sneakercloud.de/application/o/claude-web-ui/.well-known/openid-configuration`
|
||||||
|
- Documented group structure
|
||||||
|
|
||||||
|
#### 1.2 Backend Refactoring
|
||||||
|
**Current:** Single `backend/server.js` (999 lines)
|
||||||
|
|
||||||
|
**New Structure:**
|
||||||
|
```
|
||||||
|
backend/
|
||||||
|
├── server.js # Main entry, app initialization
|
||||||
|
├── config/
|
||||||
|
│ └── auth.js # Auth config from env
|
||||||
|
├── middleware/
|
||||||
|
│ ├── auth.js # Authentication middleware
|
||||||
|
│ └── session.js # Session management
|
||||||
|
├── routes/
|
||||||
|
│ ├── api.js # Existing API routes
|
||||||
|
│ ├── auth.js # OIDC auth routes
|
||||||
|
│ └── websocket.js # WebSocket handler
|
||||||
|
└── utils/
|
||||||
|
└── oidc.js # OIDC client wrapper
|
||||||
|
```
|
||||||
|
|
||||||
|
**Migration Strategy:**
|
||||||
|
1. Create new file structure
|
||||||
|
2. Extract route handlers into modules
|
||||||
|
3. Extract WebSocket logic
|
||||||
|
4. Update server.js to import modules
|
||||||
|
5. Test that existing functionality works
|
||||||
|
|
||||||
|
#### 1.3 Dependencies
|
||||||
|
**Add to `backend/package.json`:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"express-session": "^1.18.0",
|
||||||
|
"connect-redis": "^7.1.0",
|
||||||
|
"redis": "^4.6.0",
|
||||||
|
"openid-client": "^5.6.0",
|
||||||
|
"cookie-parser": "^1.4.6"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rationale:**
|
||||||
|
- `express-session`: Session management
|
||||||
|
- `connect-redis`: Session store (persistent, production-ready)
|
||||||
|
- `redis`: Session backend
|
||||||
|
- `openid-client`: Official OIDC client (certified)
|
||||||
|
- `cookie-parser`: Cookie handling
|
||||||
|
|
||||||
|
#### 1.4 Session Storage
|
||||||
|
**Redis Configuration:**
|
||||||
|
- Use existing Redis instance or add to docker-compose
|
||||||
|
- Configure session store with httpOnly cookies
|
||||||
|
- Session TTL: 24 hours (configurable)
|
||||||
|
- Secure flag in production
|
||||||
|
|
||||||
|
**Session Schema:**
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
sessionId: "uuid",
|
||||||
|
userId: "oidc-sub",
|
||||||
|
email: "user@example.com",
|
||||||
|
name: "User Name",
|
||||||
|
groups: ["agent-users"],
|
||||||
|
accessToken: "encrypted",
|
||||||
|
refreshToken: "encrypted",
|
||||||
|
expiresAt: timestamp,
|
||||||
|
createdAt: timestamp
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verification Criteria
|
||||||
|
- [ ] Authentik provider and application created
|
||||||
|
- [ ] Test user can authenticate via Authentik UI
|
||||||
|
- [ ] Backend refactored, all tests pass
|
||||||
|
- [ ] Dependencies installed
|
||||||
|
- [ ] Redis session store connected
|
||||||
|
- [ ] Session CRUD operations working
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: OIDC Authentication Flow
|
||||||
|
|
||||||
|
### Objectives
|
||||||
|
- Implement authorization code flow
|
||||||
|
- Create auth routes (login, callback, logout)
|
||||||
|
- Secure session cookies
|
||||||
|
- Handle token refresh
|
||||||
|
|
||||||
|
### Tasks
|
||||||
|
|
||||||
|
#### 2.1 OIDC Client Setup
|
||||||
|
**File:** `backend/utils/oidc.js`
|
||||||
|
|
||||||
|
**Functionality:**
|
||||||
|
- Discover OIDC configuration from Authentik
|
||||||
|
- Initialize Issuer and Client
|
||||||
|
- Generate authorization URL
|
||||||
|
- Handle token exchange
|
||||||
|
- Validate ID tokens
|
||||||
|
- Refresh access tokens
|
||||||
|
|
||||||
|
**Configuration (from env):**
|
||||||
|
```bash
|
||||||
|
OIDC_ISSUER=https://auth.sneakercloud.de/application/o/claude-web-ui/
|
||||||
|
OIDC_CLIENT_ID=<from_authentik>
|
||||||
|
OIDC_CLIENT_SECRET=<from_authentik>
|
||||||
|
OIDC_REDIRECT_URI=https://agents.sneakercloud.de/auth/callback
|
||||||
|
SESSION_SECRET=<generate_with_openssl>
|
||||||
|
REDIS_URL=redis://localhost:6379
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2.2 Authentication Routes
|
||||||
|
**File:** `backend/routes/auth.js`
|
||||||
|
|
||||||
|
**Routes:**
|
||||||
|
|
||||||
|
**GET `/auth/login`**
|
||||||
|
- Generate OIDC authorization URL with PKCE
|
||||||
|
- Store state and nonce in session
|
||||||
|
- Redirect to Authentik
|
||||||
|
|
||||||
|
**GET `/auth/callback`**
|
||||||
|
- Validate state parameter
|
||||||
|
- Exchange authorization code for tokens
|
||||||
|
- Validate ID token signature and claims
|
||||||
|
- Extract user info and groups
|
||||||
|
- Create session with user data
|
||||||
|
- Redirect to frontend (`/`)
|
||||||
|
|
||||||
|
**POST `/auth/logout`**
|
||||||
|
- Destroy session
|
||||||
|
- Optionally redirect to Authentik logout
|
||||||
|
- Clear cookies
|
||||||
|
|
||||||
|
**GET `/auth/user`**
|
||||||
|
- Return current user info from session
|
||||||
|
- Return 401 if not authenticated
|
||||||
|
|
||||||
|
**POST `/auth/refresh`**
|
||||||
|
- Use refresh token to get new access token
|
||||||
|
- Update session
|
||||||
|
- Return success/failure
|
||||||
|
|
||||||
|
#### 2.3 Session Cookie Configuration
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
name: 'claude.sid',
|
||||||
|
secret: process.env.SESSION_SECRET,
|
||||||
|
resave: false,
|
||||||
|
saveUninitialized: false,
|
||||||
|
cookie: {
|
||||||
|
httpOnly: true, // No JavaScript access
|
||||||
|
secure: true, // HTTPS only in production
|
||||||
|
sameSite: 'lax', // CSRF protection
|
||||||
|
maxAge: 24 * 60 * 60 * 1000 // 24 hours
|
||||||
|
},
|
||||||
|
store: redisStore // Redis session store
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2.4 Token Management
|
||||||
|
- Store access token in Redis session (encrypted)
|
||||||
|
- Store refresh token in Redis session (encrypted)
|
||||||
|
- Implement token refresh 5 minutes before expiry
|
||||||
|
- Handle refresh failures (force re-login)
|
||||||
|
|
||||||
|
### Verification Criteria
|
||||||
|
- [ ] Login redirects to Authentik
|
||||||
|
- [ ] Callback successfully exchanges code for tokens
|
||||||
|
- [ ] ID token validated and parsed
|
||||||
|
- [ ] Groups extracted from token
|
||||||
|
- [ ] Session created in Redis
|
||||||
|
- [ ] Cookie set with correct flags
|
||||||
|
- [ ] `/auth/user` returns user info
|
||||||
|
- [ ] Logout destroys session
|
||||||
|
- [ ] Token refresh works
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: Backend API Protection
|
||||||
|
|
||||||
|
### Objectives
|
||||||
|
- Protect all API endpoints with authentication
|
||||||
|
- Secure WebSocket connections
|
||||||
|
- Implement group-based authorization
|
||||||
|
- Handle unauthorized access gracefully
|
||||||
|
|
||||||
|
### Tasks
|
||||||
|
|
||||||
|
#### 3.1 Authentication Middleware
|
||||||
|
**File:** `backend/middleware/auth.js`
|
||||||
|
|
||||||
|
**Middleware Functions:**
|
||||||
|
|
||||||
|
**`requireAuth`**
|
||||||
|
- Check if session exists and is valid
|
||||||
|
- Verify session in Redis
|
||||||
|
- Attach `req.user` with user data
|
||||||
|
- Return 401 if not authenticated
|
||||||
|
|
||||||
|
**`requireGroup(groups)`**
|
||||||
|
- Check if `req.user.groups` includes required group
|
||||||
|
- Return 403 if insufficient permissions
|
||||||
|
- Usage: `requireGroup(['agent-admin'])`
|
||||||
|
|
||||||
|
**`optionalAuth`**
|
||||||
|
- Attach user if authenticated
|
||||||
|
- Continue if not authenticated
|
||||||
|
- For endpoints that support both modes
|
||||||
|
|
||||||
|
#### 3.2 Protect REST Endpoints
|
||||||
|
**Apply Middleware:**
|
||||||
|
```javascript
|
||||||
|
// All API routes require auth
|
||||||
|
app.use('/api', requireAuth);
|
||||||
|
|
||||||
|
// Admin-only endpoints
|
||||||
|
app.get('/api/admin/*', requireGroup(['agent-admin']));
|
||||||
|
|
||||||
|
// Health check remains public
|
||||||
|
app.get('/api/health', (req, res) => ...);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Protected Endpoints:**
|
||||||
|
- `/api/hosts` - Read: agent-users, Write: agent-admin
|
||||||
|
- `/api/projects` - Read: agent-users
|
||||||
|
- `/api/browse` - agent-users
|
||||||
|
- `/api/upload/:sessionId` - agent-users
|
||||||
|
- `/api/history/:project` - agent-users (own history only)
|
||||||
|
|
||||||
|
#### 3.3 WebSocket Authentication
|
||||||
|
**Challenge:** WebSocket upgrade happens before HTTP middleware
|
||||||
|
|
||||||
|
**Solution: Cookie-based Auth**
|
||||||
|
1. Parse cookies from upgrade request
|
||||||
|
2. Load session from Redis
|
||||||
|
3. Validate session
|
||||||
|
4. Attach user to WebSocket connection
|
||||||
|
5. Reject upgrade if not authenticated
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
```javascript
|
||||||
|
wss.on('connection', async (ws, req) => {
|
||||||
|
// Parse cookies from req.headers.cookie
|
||||||
|
// Load session from Redis
|
||||||
|
// Validate user
|
||||||
|
if (!user) {
|
||||||
|
ws.close(1008, 'Unauthorized');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach user to connection
|
||||||
|
ws.user = user;
|
||||||
|
|
||||||
|
// Continue with Claude session logic
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.4 Session Association
|
||||||
|
**Current:** Sessions stored by UUID, no user association
|
||||||
|
|
||||||
|
**New:**
|
||||||
|
- Associate Claude sessions with authenticated user
|
||||||
|
- Store user ID in session metadata
|
||||||
|
- Allow users to only access their own sessions
|
||||||
|
- Admin users can view all sessions (optional)
|
||||||
|
|
||||||
|
**Session Schema Update:**
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
sessionId: "uuid",
|
||||||
|
userId: "oidc-sub", // NEW
|
||||||
|
userEmail: "user@example.com", // NEW
|
||||||
|
project: "/path/to/project",
|
||||||
|
host: "neko",
|
||||||
|
// ... existing fields
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.5 Error Handling
|
||||||
|
- 401 Unauthorized: Not authenticated
|
||||||
|
- 403 Forbidden: Insufficient permissions
|
||||||
|
- Redirect to `/auth/login` for API calls
|
||||||
|
- WebSocket: Close with error code
|
||||||
|
- Frontend: Display login prompt
|
||||||
|
|
||||||
|
### Verification Criteria
|
||||||
|
- [ ] Unauthenticated API calls return 401
|
||||||
|
- [ ] WebSocket upgrades require valid session
|
||||||
|
- [ ] Group-based access control works
|
||||||
|
- [ ] Admin users have extended permissions
|
||||||
|
- [ ] Sessions isolated by user
|
||||||
|
- [ ] Error responses are clear
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: Frontend Authentication UI
|
||||||
|
|
||||||
|
### Objectives
|
||||||
|
- Create AuthContext for authentication state
|
||||||
|
- Implement login/logout UI
|
||||||
|
- Add protected routes
|
||||||
|
- Handle authentication errors
|
||||||
|
- Integrate with existing SessionContext
|
||||||
|
|
||||||
|
### Tasks
|
||||||
|
|
||||||
|
#### 4.1 AuthContext
|
||||||
|
**File:** `frontend/src/contexts/AuthContext.jsx`
|
||||||
|
|
||||||
|
**State:**
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
user: null | {
|
||||||
|
id: string,
|
||||||
|
email: string,
|
||||||
|
name: string,
|
||||||
|
groups: string[]
|
||||||
|
},
|
||||||
|
isLoading: boolean,
|
||||||
|
error: string | null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Methods:**
|
||||||
|
- `login()` - Redirect to `/auth/login`
|
||||||
|
- `logout()` - Call `/auth/logout`, clear state
|
||||||
|
- `refreshUser()` - Call `/auth/user` to get current user
|
||||||
|
- `checkAuth()` - Verify authentication on mount
|
||||||
|
|
||||||
|
**Auto-refresh:**
|
||||||
|
- Poll `/auth/user` every 5 minutes
|
||||||
|
- Handle 401 by redirecting to login
|
||||||
|
- Update user state on success
|
||||||
|
|
||||||
|
#### 4.2 Login Flow
|
||||||
|
**Current:** App loads directly to ChatPanel
|
||||||
|
|
||||||
|
**New:**
|
||||||
|
1. App mounts
|
||||||
|
2. AuthContext checks `/auth/user`
|
||||||
|
3. If 401: Show login page
|
||||||
|
4. If 200: Show app
|
||||||
|
5. User clicks "Login with Authentik"
|
||||||
|
6. Redirect to `/auth/login`
|
||||||
|
7. Authentik authentication
|
||||||
|
8. Callback redirects to `/`
|
||||||
|
9. AuthContext refreshes user
|
||||||
|
10. App shows
|
||||||
|
|
||||||
|
#### 4.3 Login Page Component
|
||||||
|
**File:** `frontend/src/components/LoginPage.jsx`
|
||||||
|
|
||||||
|
**UI:**
|
||||||
|
- Centered card
|
||||||
|
- Claude Web UI branding
|
||||||
|
- "Login with Authentik" button
|
||||||
|
- Loading state during redirect
|
||||||
|
- Error messages
|
||||||
|
|
||||||
|
**Design:**
|
||||||
|
- Match existing dark theme
|
||||||
|
- Simple, professional
|
||||||
|
- No local credentials (OIDC only)
|
||||||
|
|
||||||
|
#### 4.4 Protected App Wrapper
|
||||||
|
**File:** `frontend/src/App.jsx` (update)
|
||||||
|
|
||||||
|
**Logic:**
|
||||||
|
```javascript
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<AuthProvider>
|
||||||
|
<AuthenticatedApp />
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AuthenticatedApp() {
|
||||||
|
const { user, isLoading } = useAuth();
|
||||||
|
|
||||||
|
if (isLoading) return <LoadingSpinner />;
|
||||||
|
if (!user) return <LoginPage />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SessionProvider>
|
||||||
|
<AppContent />
|
||||||
|
</SessionProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4.5 User Menu
|
||||||
|
**Location:** Header component
|
||||||
|
|
||||||
|
**Elements:**
|
||||||
|
- User name/email display
|
||||||
|
- Group badges (admin/user)
|
||||||
|
- Logout button
|
||||||
|
- Account settings link (optional)
|
||||||
|
|
||||||
|
**Functionality:**
|
||||||
|
- Dropdown menu
|
||||||
|
- Logout calls `logout()` method
|
||||||
|
- Shows current user info
|
||||||
|
|
||||||
|
#### 4.6 Session Integration
|
||||||
|
**Update:** `frontend/src/contexts/SessionContext.jsx`
|
||||||
|
|
||||||
|
**Changes:**
|
||||||
|
- Include user in WebSocket connection metadata
|
||||||
|
- Filter sessions by current user
|
||||||
|
- Add user info to session creation
|
||||||
|
- Handle 401 errors by triggering AuthContext logout
|
||||||
|
|
||||||
|
**WebSocket Headers:**
|
||||||
|
- Session cookie automatically included
|
||||||
|
- Backend validates on upgrade
|
||||||
|
|
||||||
|
### Verification Criteria
|
||||||
|
- [ ] Unauthenticated users see login page
|
||||||
|
- [ ] Login redirects to Authentik
|
||||||
|
- [ ] Successful login shows app
|
||||||
|
- [ ] User info displayed in UI
|
||||||
|
- [ ] Logout clears session and returns to login
|
||||||
|
- [ ] 401 errors trigger re-authentication
|
||||||
|
- [ ] WebSocket connects with auth
|
||||||
|
- [ ] Sessions filtered by user
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: Production Hardening & Polish
|
||||||
|
|
||||||
|
### Objectives
|
||||||
|
- Implement security best practices
|
||||||
|
- Add monitoring and logging
|
||||||
|
- Handle edge cases
|
||||||
|
- Document configuration
|
||||||
|
- Test thoroughly
|
||||||
|
|
||||||
|
### Tasks
|
||||||
|
|
||||||
|
#### 5.1 Security Enhancements
|
||||||
|
|
||||||
|
**CSRF Protection:**
|
||||||
|
- Use `sameSite: 'lax'` on cookies
|
||||||
|
- Add CSRF tokens for state-changing operations
|
||||||
|
- Validate origin headers on WebSocket upgrade
|
||||||
|
|
||||||
|
**Rate Limiting:**
|
||||||
|
- Add `express-rate-limit` to auth endpoints
|
||||||
|
- Limit login attempts per IP
|
||||||
|
- Limit token refresh attempts
|
||||||
|
|
||||||
|
**Token Security:**
|
||||||
|
- Encrypt tokens at rest in Redis (using `crypto`)
|
||||||
|
- Validate token expiry before use
|
||||||
|
- Clear tokens on logout
|
||||||
|
|
||||||
|
**Content Security Policy:**
|
||||||
|
- Add CSP headers
|
||||||
|
- Allow only Authentik domain for redirects
|
||||||
|
- Restrict inline scripts
|
||||||
|
|
||||||
|
#### 5.2 Logging & Monitoring
|
||||||
|
|
||||||
|
**Authentication Events:**
|
||||||
|
- Log successful logins (user ID, IP, timestamp)
|
||||||
|
- Log failed auth attempts
|
||||||
|
- Log token refresh failures
|
||||||
|
- Log logout events
|
||||||
|
|
||||||
|
**Metrics:**
|
||||||
|
- Active sessions count
|
||||||
|
- Authentication failures rate
|
||||||
|
- Token refresh rate
|
||||||
|
- WebSocket auth failures
|
||||||
|
|
||||||
|
**Integration:**
|
||||||
|
- Use existing Gotify webhook for alerts
|
||||||
|
- Log to stdout (Docker logs)
|
||||||
|
- Consider structured logging (winston)
|
||||||
|
|
||||||
|
#### 5.3 Error Handling
|
||||||
|
|
||||||
|
**Token Expiry:**
|
||||||
|
- Detect access token expiry
|
||||||
|
- Auto-refresh using refresh token
|
||||||
|
- Prompt re-login if refresh fails
|
||||||
|
- Show clear error messages
|
||||||
|
|
||||||
|
**Session Expiry:**
|
||||||
|
- Handle expired sessions gracefully
|
||||||
|
- Show "Session expired, please login again"
|
||||||
|
- Preserve unsaved work if possible
|
||||||
|
|
||||||
|
**Network Errors:**
|
||||||
|
- Retry logic for auth endpoint calls
|
||||||
|
- Offline detection
|
||||||
|
- Clear error messages
|
||||||
|
|
||||||
|
**Authentik Downtime:**
|
||||||
|
- Cache user info for graceful degradation
|
||||||
|
- Allow continued use of valid sessions
|
||||||
|
- Queue auth checks
|
||||||
|
|
||||||
|
#### 5.4 Environment Configuration
|
||||||
|
|
||||||
|
**Required Env Vars:**
|
||||||
|
```bash
|
||||||
|
# OIDC
|
||||||
|
OIDC_ISSUER=https://auth.sneakercloud.de/application/o/claude-web-ui/
|
||||||
|
OIDC_CLIENT_ID=<client_id>
|
||||||
|
OIDC_CLIENT_SECRET=<client_secret>
|
||||||
|
OIDC_REDIRECT_URI=https://agents.sneakercloud.de/auth/callback
|
||||||
|
|
||||||
|
# Session
|
||||||
|
SESSION_SECRET=<generate_with_openssl_rand_hex_32>
|
||||||
|
SESSION_DOMAIN=.sneakercloud.de
|
||||||
|
SESSION_SECURE=true
|
||||||
|
SESSION_MAX_AGE=86400000 # 24 hours
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
REDIS_URL=redis://localhost:6379
|
||||||
|
REDIS_PASSWORD=<optional>
|
||||||
|
|
||||||
|
# App
|
||||||
|
NODE_ENV=production
|
||||||
|
FRONTEND_URL=https://agents.sneakercloud.de
|
||||||
|
```
|
||||||
|
|
||||||
|
**docker-compose.yml Updates:**
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
backend:
|
||||||
|
environment:
|
||||||
|
- OIDC_ISSUER=${OIDC_ISSUER}
|
||||||
|
- OIDC_CLIENT_ID=${OIDC_CLIENT_ID}
|
||||||
|
- OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET}
|
||||||
|
- SESSION_SECRET=${SESSION_SECRET}
|
||||||
|
- REDIS_URL=redis://redis:6379
|
||||||
|
depends_on:
|
||||||
|
- redis
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
networks:
|
||||||
|
- claude-internal
|
||||||
|
volumes:
|
||||||
|
- redis-data:/data
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
redis-data:
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5.5 Documentation
|
||||||
|
|
||||||
|
**Create Files:**
|
||||||
|
- `docs/AUTHENTICATION.md` - Overview of auth system
|
||||||
|
- `docs/SETUP.md` - Step-by-step Authentik setup
|
||||||
|
- `docs/CONFIGURATION.md` - Environment variables
|
||||||
|
- `docs/TROUBLESHOOTING.md` - Common issues
|
||||||
|
|
||||||
|
**Update README.md:**
|
||||||
|
- Add authentication section
|
||||||
|
- Document required groups
|
||||||
|
- Link to setup docs
|
||||||
|
|
||||||
|
#### 5.6 Testing
|
||||||
|
|
||||||
|
**Manual Tests:**
|
||||||
|
- [ ] Fresh login flow
|
||||||
|
- [ ] Logout and re-login
|
||||||
|
- [ ] Session persistence across browser refresh
|
||||||
|
- [ ] Token refresh
|
||||||
|
- [ ] Expired session handling
|
||||||
|
- [ ] WebSocket auth
|
||||||
|
- [ ] Group-based access control
|
||||||
|
- [ ] Multiple users simultaneously
|
||||||
|
- [ ] Admin vs regular user permissions
|
||||||
|
|
||||||
|
**Edge Cases:**
|
||||||
|
- [ ] Authentik downtime during login
|
||||||
|
- [ ] Redis downtime (session loss)
|
||||||
|
- [ ] Concurrent logins from same user
|
||||||
|
- [ ] Token refresh during active WebSocket
|
||||||
|
- [ ] Cookie disabled in browser
|
||||||
|
- [ ] CORS issues
|
||||||
|
|
||||||
|
**Security Tests:**
|
||||||
|
- [ ] Cannot access API without auth
|
||||||
|
- [ ] Cannot access other users' sessions
|
||||||
|
- [ ] XSS attacks blocked (httpOnly cookies)
|
||||||
|
- [ ] CSRF protection works
|
||||||
|
- [ ] Token replay attacks prevented
|
||||||
|
|
||||||
|
### Verification Criteria
|
||||||
|
- [ ] All security enhancements implemented
|
||||||
|
- [ ] Logging captures auth events
|
||||||
|
- [ ] Error handling covers all cases
|
||||||
|
- [ ] Documentation complete
|
||||||
|
- [ ] All tests pass
|
||||||
|
- [ ] Production deployment successful
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Timeline
|
||||||
|
|
||||||
|
**Phase 1:** Authentik Configuration & Backend Foundation
|
||||||
|
**Duration:** 2-3 days
|
||||||
|
**Blockers:** None
|
||||||
|
|
||||||
|
**Phase 2:** OIDC Authentication Flow
|
||||||
|
**Duration:** 2-3 days
|
||||||
|
**Blockers:** Phase 1 complete
|
||||||
|
|
||||||
|
**Phase 3:** Backend API Protection
|
||||||
|
**Duration:** 2-3 days
|
||||||
|
**Blockers:** Phase 2 complete
|
||||||
|
|
||||||
|
**Phase 4:** Frontend Authentication UI
|
||||||
|
**Duration:** 3-4 days
|
||||||
|
**Blockers:** Phase 3 complete
|
||||||
|
|
||||||
|
**Phase 5:** Production Hardening & Polish
|
||||||
|
**Duration:** 2-3 days
|
||||||
|
**Blockers:** Phase 4 complete
|
||||||
|
|
||||||
|
**Total Estimated Time:** 11-16 days
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Success Metrics
|
||||||
|
|
||||||
|
1. **Security:**
|
||||||
|
- Zero XSS vulnerabilities (httpOnly cookies)
|
||||||
|
- Zero unauthorized access incidents
|
||||||
|
- All tokens encrypted at rest
|
||||||
|
|
||||||
|
2. **User Experience:**
|
||||||
|
- Seamless login flow (<5 seconds)
|
||||||
|
- Clear error messages
|
||||||
|
- No unnecessary re-authentication
|
||||||
|
|
||||||
|
3. **Reliability:**
|
||||||
|
- 99.9% authentication availability
|
||||||
|
- Session persistence across restarts
|
||||||
|
- Graceful degradation on errors
|
||||||
|
|
||||||
|
4. **Performance:**
|
||||||
|
- <100ms auth middleware overhead
|
||||||
|
- No impact on WebSocket latency
|
||||||
|
- Redis session lookups <10ms
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rollback Plan
|
||||||
|
|
||||||
|
**If Critical Issues Arise:**
|
||||||
|
|
||||||
|
1. **Immediate:** Disable authentication middleware
|
||||||
|
2. **Revert:** Return to unauthenticated mode
|
||||||
|
3. **Investigate:** Review logs and error reports
|
||||||
|
4. **Fix:** Address issues in isolated environment
|
||||||
|
5. **Redeploy:** With fixes applied
|
||||||
|
|
||||||
|
**Feature Flags:**
|
||||||
|
- `AUTH_ENABLED=false` to disable auth
|
||||||
|
- Keep unauthenticated code path intact during initial rollout
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
**Post-MVP:**
|
||||||
|
- Multi-factor authentication (MFA) via Authentik
|
||||||
|
- API keys for programmatic access
|
||||||
|
- OAuth2 scopes for fine-grained permissions
|
||||||
|
- Audit log for all user actions
|
||||||
|
- Session management UI (view/revoke sessions)
|
||||||
|
- SSO with other services via Authentik
|
||||||
|
- User preferences storage
|
||||||
|
- Role-based access control (RBAC) expansion
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- **Authentik Docs:** https://docs.goauthentik.io/
|
||||||
|
- **OIDC Spec:** https://openid.net/connect/
|
||||||
|
- **openid-client:** https://github.com/panva/node-openid-client
|
||||||
|
- **express-session:** https://github.com/expressjs/session
|
||||||
|
- **OWASP Auth Cheatsheet:** https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
|
||||||
62
backend/config/auth.js
Normal file
62
backend/config/auth.js
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
// Auth configuration from environment variables
|
||||||
|
|
||||||
|
export const authConfig = {
|
||||||
|
// OIDC Configuration
|
||||||
|
oidc: {
|
||||||
|
issuer: process.env.OIDC_ISSUER,
|
||||||
|
clientId: process.env.OIDC_CLIENT_ID,
|
||||||
|
clientSecret: process.env.OIDC_CLIENT_SECRET,
|
||||||
|
redirectUri: process.env.OIDC_REDIRECT_URI,
|
||||||
|
scopes: ['openid', 'profile', 'email', 'groups', 'offline_access'],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Session Configuration
|
||||||
|
session: {
|
||||||
|
secret: process.env.SESSION_SECRET,
|
||||||
|
name: 'claude.sid',
|
||||||
|
domain: process.env.SESSION_DOMAIN || undefined,
|
||||||
|
secure: process.env.SESSION_SECURE === 'true',
|
||||||
|
maxAge: parseInt(process.env.SESSION_MAX_AGE) || 86400000, // 24 hours
|
||||||
|
},
|
||||||
|
|
||||||
|
// Redis Configuration
|
||||||
|
redis: {
|
||||||
|
url: process.env.REDIS_URL || 'redis://localhost:6379',
|
||||||
|
},
|
||||||
|
|
||||||
|
// App Configuration
|
||||||
|
app: {
|
||||||
|
frontendUrl: process.env.FRONTEND_URL || 'http://localhost:5173',
|
||||||
|
authEnabled: process.env.AUTH_ENABLED !== 'false',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Group Configuration (must match Authentik group names)
|
||||||
|
groups: {
|
||||||
|
admin: 'agent-admins',
|
||||||
|
users: 'agent-users',
|
||||||
|
allowedGroups: ['agent-admins', 'agent-users'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Validate required config
|
||||||
|
export function validateConfig() {
|
||||||
|
const { oidc, session, app } = authConfig;
|
||||||
|
const errors = [];
|
||||||
|
|
||||||
|
if (app.authEnabled) {
|
||||||
|
if (!oidc.issuer) errors.push('OIDC_ISSUER is required');
|
||||||
|
if (!oidc.clientId) errors.push('OIDC_CLIENT_ID is required');
|
||||||
|
if (!oidc.clientSecret) errors.push('OIDC_CLIENT_SECRET is required');
|
||||||
|
if (!oidc.redirectUri) errors.push('OIDC_REDIRECT_URI is required');
|
||||||
|
if (!session.secret) errors.push('SESSION_SECRET is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
console.error('Auth configuration errors:', errors);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default authConfig;
|
||||||
184
backend/middleware/auth.js
Normal file
184
backend/middleware/auth.js
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
// Authentication middleware
|
||||||
|
|
||||||
|
import { authConfig } from '../config/auth.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Require authentication for a route
|
||||||
|
* Sets req.user if authenticated, returns 401 if not
|
||||||
|
*/
|
||||||
|
export function requireAuth(req, res, next) {
|
||||||
|
console.log(`[requireAuth] ${req.method} ${req.path} - session ID: ${req.session?.id || 'NONE'}, has user: ${!!req.session?.user}`);
|
||||||
|
|
||||||
|
// Skip auth if disabled
|
||||||
|
if (!authConfig.app.authEnabled) {
|
||||||
|
req.user = { id: 'anonymous', email: 'anonymous@local', groups: ['agent-admin'] };
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check session
|
||||||
|
if (!req.session || !req.session.user) {
|
||||||
|
console.log(`[requireAuth] Unauthorized - no session or user`);
|
||||||
|
return res.status(401).json({
|
||||||
|
error: 'Unauthorized',
|
||||||
|
message: 'Authentication required',
|
||||||
|
loginUrl: '/auth/login',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach user to request
|
||||||
|
req.user = req.session.user;
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Require specific group membership
|
||||||
|
* Must be used after requireAuth
|
||||||
|
*/
|
||||||
|
export function requireGroup(allowedGroups) {
|
||||||
|
return (req, res, next) => {
|
||||||
|
// Skip if auth disabled
|
||||||
|
if (!authConfig.app.authEnabled) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!req.user) {
|
||||||
|
return res.status(401).json({
|
||||||
|
error: 'Unauthorized',
|
||||||
|
message: 'Authentication required',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const userGroups = req.user.groups || [];
|
||||||
|
const hasGroup = allowedGroups.some(g => userGroups.includes(g));
|
||||||
|
|
||||||
|
if (!hasGroup) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: 'Forbidden',
|
||||||
|
message: `Required group: ${allowedGroups.join(' or ')}`,
|
||||||
|
userGroups,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional authentication - attach user if present but don't require it
|
||||||
|
*/
|
||||||
|
export function optionalAuth(req, res, next) {
|
||||||
|
if (!authConfig.app.authEnabled) {
|
||||||
|
req.user = null;
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.session && req.session.user) {
|
||||||
|
req.user = req.session.user;
|
||||||
|
} else {
|
||||||
|
req.user = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user is admin
|
||||||
|
*/
|
||||||
|
export function isAdmin(user) {
|
||||||
|
if (!user || !user.groups) return false;
|
||||||
|
return user.groups.includes(authConfig.groups.admin);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticate WebSocket upgrade request
|
||||||
|
* Returns user object or null
|
||||||
|
*/
|
||||||
|
export async function authenticateWebSocket(req, sessionStore) {
|
||||||
|
// Skip auth if disabled
|
||||||
|
if (!authConfig.app.authEnabled) {
|
||||||
|
return { id: 'anonymous', email: 'anonymous@local', groups: ['agent-admin'] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse session ID from cookies
|
||||||
|
const cookies = parseCookies(req.headers.cookie || '');
|
||||||
|
const sessionCookie = cookies[authConfig.session.name];
|
||||||
|
|
||||||
|
if (!sessionCookie) {
|
||||||
|
console.log('[WS Auth] No session cookie found');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session cookie format: s:sessionId.signature
|
||||||
|
// We need to extract the session ID
|
||||||
|
const sessionId = decodeSessionCookie(sessionCookie, authConfig.session.secret);
|
||||||
|
|
||||||
|
if (!sessionId) {
|
||||||
|
console.log('[WS Auth] Invalid session cookie');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load session from store
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
sessionStore.get(sessionId, (err, session) => {
|
||||||
|
if (err || !session || !session.user) {
|
||||||
|
console.log('[WS Auth] Session not found or invalid');
|
||||||
|
resolve(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[WS Auth] Authenticated user: ${session.user.email}`);
|
||||||
|
resolve(session.user);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse cookies from header string
|
||||||
|
*/
|
||||||
|
function parseCookies(cookieHeader) {
|
||||||
|
const cookies = {};
|
||||||
|
if (!cookieHeader) return cookies;
|
||||||
|
|
||||||
|
cookieHeader.split(';').forEach(cookie => {
|
||||||
|
const [name, ...rest] = cookie.trim().split('=');
|
||||||
|
cookies[name] = rest.join('=');
|
||||||
|
});
|
||||||
|
|
||||||
|
return cookies;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode express-session cookie
|
||||||
|
* Cookie format: s:sessionId.signature (URL encoded)
|
||||||
|
*/
|
||||||
|
function decodeSessionCookie(cookie, secret) {
|
||||||
|
try {
|
||||||
|
// URL decode
|
||||||
|
const decoded = decodeURIComponent(cookie);
|
||||||
|
|
||||||
|
// Check for signed cookie prefix
|
||||||
|
if (!decoded.startsWith('s:')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract session ID (before the signature)
|
||||||
|
const withoutPrefix = decoded.substring(2);
|
||||||
|
const dotIndex = withoutPrefix.lastIndexOf('.');
|
||||||
|
|
||||||
|
if (dotIndex === -1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return withoutPrefix.substring(0, dotIndex);
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
requireAuth,
|
||||||
|
requireGroup,
|
||||||
|
optionalAuth,
|
||||||
|
isAdmin,
|
||||||
|
authenticateWebSocket,
|
||||||
|
};
|
||||||
@@ -13,6 +13,11 @@
|
|||||||
"ws": "^8.14.2",
|
"ws": "^8.14.2",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"uuid": "^9.0.0",
|
"uuid": "^9.0.0",
|
||||||
"multer": "^1.4.5-lts.1"
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"express-session": "^1.18.0",
|
||||||
|
"connect-redis": "^7.1.0",
|
||||||
|
"redis": "^4.6.0",
|
||||||
|
"openid-client": "^5.6.0",
|
||||||
|
"cookie-parser": "^1.4.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
262
backend/routes/auth.js
Normal file
262
backend/routes/auth.js
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
// Authentication routes
|
||||||
|
|
||||||
|
import { Router } from 'express';
|
||||||
|
import { authConfig } from '../config/auth.js';
|
||||||
|
import oidc from '../utils/oidc.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /auth/login
|
||||||
|
* Redirect to OIDC provider for authentication
|
||||||
|
*/
|
||||||
|
router.get('/login', async (req, res) => {
|
||||||
|
if (!authConfig.app.authEnabled) {
|
||||||
|
return res.redirect(authConfig.app.frontendUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Generate PKCE and state values
|
||||||
|
const state = oidc.generateState();
|
||||||
|
const nonce = oidc.generateNonce();
|
||||||
|
const codeVerifier = oidc.generateCodeVerifier();
|
||||||
|
|
||||||
|
// Store in session for validation
|
||||||
|
req.session.authState = state;
|
||||||
|
req.session.authNonce = nonce;
|
||||||
|
req.session.codeVerifier = codeVerifier;
|
||||||
|
|
||||||
|
// Store return URL if provided
|
||||||
|
const returnTo = req.query.returnTo || '/';
|
||||||
|
req.session.returnTo = returnTo;
|
||||||
|
|
||||||
|
// Get authorization URL
|
||||||
|
const authUrl = oidc.getAuthorizationUrl(state, nonce, codeVerifier);
|
||||||
|
|
||||||
|
// Explicitly save session before redirect (required for async stores like Redis)
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
req.session.save((err) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[Auth] Login initiated, session ID: ${req.session.id}, state: ${state.substring(0, 10)}...`);
|
||||||
|
res.redirect(authUrl);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Auth] Login error:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to initiate login' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /auth/callback
|
||||||
|
* Handle OIDC callback after authentication
|
||||||
|
*/
|
||||||
|
router.get('/callback', async (req, res) => {
|
||||||
|
if (!authConfig.app.authEnabled) {
|
||||||
|
return res.redirect(authConfig.app.frontendUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { code, state, error, error_description } = req.query;
|
||||||
|
|
||||||
|
// Check for error from provider
|
||||||
|
if (error) {
|
||||||
|
console.error(`[Auth] Provider error: ${error} - ${error_description}`);
|
||||||
|
return res.redirect(`${authConfig.app.frontendUrl}/login?error=${encodeURIComponent(error_description || error)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate state
|
||||||
|
console.log(`[Auth] Callback received, session ID: ${req.session?.id}, stored state: ${req.session?.authState?.substring(0, 10) || 'NONE'}..., received state: ${state?.substring(0, 10)}...`);
|
||||||
|
if (state !== req.session.authState) {
|
||||||
|
console.error(`[Auth] State mismatch - expected: ${req.session.authState}, got: ${state}`);
|
||||||
|
return res.redirect(`${authConfig.app.frontendUrl}/login?error=invalid_state`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exchange code for tokens (nonce validation happens inside openid-client)
|
||||||
|
const tokenSet = await oidc.exchangeCode(code, req.session.codeVerifier, req.session.authNonce);
|
||||||
|
|
||||||
|
// Get claims from validated token
|
||||||
|
const claims = oidc.getIdTokenClaims(tokenSet);
|
||||||
|
|
||||||
|
// Extract user info
|
||||||
|
const userInfo = await oidc.getUserInfo(tokenSet.access_token);
|
||||||
|
|
||||||
|
// Debug: Log claims and userInfo to see available fields
|
||||||
|
console.log('[Auth] ID Token claims:', JSON.stringify(claims, null, 2));
|
||||||
|
console.log('[Auth] UserInfo:', JSON.stringify(userInfo, null, 2));
|
||||||
|
|
||||||
|
// Extract groups from claims or userInfo
|
||||||
|
let groups = claims.groups || userInfo.groups || [];
|
||||||
|
|
||||||
|
// Filter to allowed groups
|
||||||
|
groups = groups.filter(g => authConfig.groups.allowedGroups.includes(g));
|
||||||
|
|
||||||
|
// Check if user has any allowed group
|
||||||
|
if (groups.length === 0) {
|
||||||
|
console.error(`[Auth] User ${userInfo.email} has no allowed groups`);
|
||||||
|
return res.redirect(`${authConfig.app.frontendUrl}/login?error=no_access`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create user session
|
||||||
|
const user = {
|
||||||
|
id: claims.sub,
|
||||||
|
email: userInfo.email || claims.email,
|
||||||
|
name: userInfo.name || claims.name || userInfo.preferred_username,
|
||||||
|
avatar: userInfo.picture || claims.picture || null,
|
||||||
|
groups,
|
||||||
|
isAdmin: groups.includes(authConfig.groups.admin),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Store user and tokens in session
|
||||||
|
req.session.user = user;
|
||||||
|
req.session.tokens = {
|
||||||
|
accessToken: tokenSet.access_token,
|
||||||
|
refreshToken: tokenSet.refresh_token,
|
||||||
|
idToken: tokenSet.id_token,
|
||||||
|
expiresAt: tokenSet.expires_at ? tokenSet.expires_at * 1000 : Date.now() + 3600000,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Debug: Log token info
|
||||||
|
console.log(`[Auth] Token info - expires_at: ${tokenSet.expires_at}, calculated expiresAt: ${req.session.tokens.expiresAt}, has refresh_token: ${!!tokenSet.refresh_token}`);
|
||||||
|
|
||||||
|
// Clear auth state
|
||||||
|
delete req.session.authState;
|
||||||
|
delete req.session.authNonce;
|
||||||
|
delete req.session.codeVerifier;
|
||||||
|
|
||||||
|
// Get return URL
|
||||||
|
const returnTo = req.session.returnTo || '/';
|
||||||
|
delete req.session.returnTo;
|
||||||
|
|
||||||
|
console.log(`[Auth] User ${user.email} logged in successfully (groups: ${groups.join(', ')})`);
|
||||||
|
|
||||||
|
// Redirect to frontend
|
||||||
|
res.redirect(`${authConfig.app.frontendUrl}${returnTo}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Auth] Callback error:', error);
|
||||||
|
res.redirect(`${authConfig.app.frontendUrl}/login?error=auth_failed`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /auth/logout
|
||||||
|
* Clear session and optionally redirect to OIDC logout
|
||||||
|
*/
|
||||||
|
router.post('/logout', (req, res) => {
|
||||||
|
if (!authConfig.app.authEnabled) {
|
||||||
|
return res.json({ success: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const idToken = req.session.tokens?.idToken;
|
||||||
|
const userEmail = req.session.user?.email;
|
||||||
|
|
||||||
|
// Destroy session
|
||||||
|
req.session.destroy((err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('[Auth] Logout error:', err);
|
||||||
|
return res.status(500).json({ error: 'Logout failed' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear cookie
|
||||||
|
res.clearCookie(authConfig.session.name);
|
||||||
|
|
||||||
|
console.log(`[Auth] User ${userEmail || 'unknown'} logged out`);
|
||||||
|
|
||||||
|
// Return logout URL if available
|
||||||
|
if (idToken) {
|
||||||
|
try {
|
||||||
|
const logoutUrl = oidc.getEndSessionUrl(idToken, authConfig.app.frontendUrl);
|
||||||
|
return res.json({ success: true, logoutUrl });
|
||||||
|
} catch (e) {
|
||||||
|
// Issuer might not support end_session
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /auth/user
|
||||||
|
* Get current authenticated user
|
||||||
|
*/
|
||||||
|
router.get('/user', (req, res) => {
|
||||||
|
console.log(`[Auth] /user called, session ID: ${req.session?.id}, has user: ${!!req.session?.user}`);
|
||||||
|
|
||||||
|
if (!authConfig.app.authEnabled) {
|
||||||
|
return res.json({
|
||||||
|
user: { id: 'anonymous', email: 'anonymous@local', name: 'Anonymous', groups: ['agent-admin'], isAdmin: true },
|
||||||
|
authEnabled: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!req.session || !req.session.user) {
|
||||||
|
console.log(`[Auth] /user - not authenticated, session: ${JSON.stringify(req.session || {})}`);
|
||||||
|
return res.status(401).json({ error: 'Not authenticated' });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Auth] /user - returning user: ${req.session.user.email}`);
|
||||||
|
res.json({
|
||||||
|
user: req.session.user,
|
||||||
|
authEnabled: true,
|
||||||
|
expiresAt: req.session.tokens?.expiresAt,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /auth/refresh
|
||||||
|
* Refresh access token
|
||||||
|
*/
|
||||||
|
router.post('/refresh', async (req, res) => {
|
||||||
|
console.log(`[Auth] /refresh called, session ID: ${req.session?.id}, has refreshToken: ${!!req.session?.tokens?.refreshToken}`);
|
||||||
|
|
||||||
|
if (!authConfig.app.authEnabled) {
|
||||||
|
return res.json({ success: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!req.session || !req.session.tokens?.refreshToken) {
|
||||||
|
console.log('[Auth] /refresh - no refresh token in session');
|
||||||
|
return res.status(401).json({ error: 'No refresh token' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const newTokenSet = await oidc.refreshTokens(req.session.tokens.refreshToken);
|
||||||
|
|
||||||
|
req.session.tokens = {
|
||||||
|
accessToken: newTokenSet.access_token,
|
||||||
|
refreshToken: newTokenSet.refresh_token || req.session.tokens.refreshToken,
|
||||||
|
idToken: newTokenSet.id_token || req.session.tokens.idToken,
|
||||||
|
expiresAt: newTokenSet.expires_at ? newTokenSet.expires_at * 1000 : Date.now() + 3600000,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(`[Auth] Token refreshed for user ${req.session.user?.email}`);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
expiresAt: req.session.tokens.expiresAt,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Auth] Token refresh failed:', error.message);
|
||||||
|
res.status(401).json({ error: 'Token refresh failed', message: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /auth/status
|
||||||
|
* Check auth status and config
|
||||||
|
*/
|
||||||
|
router.get('/status', (req, res) => {
|
||||||
|
res.json({
|
||||||
|
authEnabled: authConfig.app.authEnabled,
|
||||||
|
isAuthenticated: !!(req.session && req.session.user),
|
||||||
|
user: req.session?.user ? {
|
||||||
|
email: req.session.user.email,
|
||||||
|
name: req.session.user.name,
|
||||||
|
isAdmin: req.session.user.isAdmin,
|
||||||
|
} : null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -7,10 +7,32 @@ import cors from 'cors';
|
|||||||
import { existsSync, readFileSync, readdirSync, statSync, mkdirSync, writeFileSync } from 'fs';
|
import { existsSync, readFileSync, readdirSync, statSync, mkdirSync, writeFileSync } from 'fs';
|
||||||
import { join, basename, extname } from 'path';
|
import { join, basename, extname } from 'path';
|
||||||
import multer from 'multer';
|
import multer from 'multer';
|
||||||
|
import session from 'express-session';
|
||||||
|
import { createClient } from 'redis';
|
||||||
|
import RedisStore from 'connect-redis';
|
||||||
|
import cookieParser from 'cookie-parser';
|
||||||
|
|
||||||
|
// Auth modules
|
||||||
|
import { authConfig, validateConfig } from './config/auth.js';
|
||||||
|
import { initializeOIDC } from './utils/oidc.js';
|
||||||
|
import { requireAuth, optionalAuth, authenticateWebSocket } from './middleware/auth.js';
|
||||||
|
import authRoutes from './routes/auth.js';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(cors());
|
|
||||||
|
// Trust proxy - required for secure cookies behind reverse proxy (NPM)
|
||||||
|
app.set('trust proxy', 1);
|
||||||
|
|
||||||
|
// CORS configuration - allow credentials for cookies
|
||||||
|
app.use(cors({
|
||||||
|
origin: authConfig.app.frontendUrl,
|
||||||
|
credentials: true,
|
||||||
|
}));
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
app.use(cookieParser());
|
||||||
|
|
||||||
|
// Session store reference (set after Redis connection)
|
||||||
|
let sessionStore = null;
|
||||||
|
|
||||||
const PORT = process.env.PORT || 3001;
|
const PORT = process.env.PORT || 3001;
|
||||||
const HOST = process.env.HOST || '0.0.0.0';
|
const HOST = process.env.HOST || '0.0.0.0';
|
||||||
@@ -137,8 +159,11 @@ function scanProjects(basePath, depth = 0, maxDepth = 1) {
|
|||||||
return projects;
|
return projects;
|
||||||
}
|
}
|
||||||
|
|
||||||
// REST endpoint to list hosts
|
// Function to register API routes (called after session middleware is set up)
|
||||||
app.get('/api/hosts', (req, res) => {
|
function registerApiRoutes() {
|
||||||
|
|
||||||
|
// REST endpoint to list hosts (protected)
|
||||||
|
app.get('/api/hosts', requireAuth, (req, res) => {
|
||||||
const hosts = Object.entries(hostsConfig.hosts).map(([id, host]) => ({
|
const hosts = Object.entries(hostsConfig.hosts).map(([id, host]) => ({
|
||||||
id,
|
id,
|
||||||
name: host.name,
|
name: host.name,
|
||||||
@@ -151,8 +176,8 @@ app.get('/api/hosts', (req, res) => {
|
|||||||
res.json({ hosts, defaultHost: hostsConfig.defaults?.host || 'neko' });
|
res.json({ hosts, defaultHost: hostsConfig.defaults?.host || 'neko' });
|
||||||
});
|
});
|
||||||
|
|
||||||
// REST endpoint to list projects for a host
|
// REST endpoint to list projects for a host (protected)
|
||||||
app.get('/api/projects', (req, res) => {
|
app.get('/api/projects', requireAuth, (req, res) => {
|
||||||
const hostId = req.query.host || hostsConfig.defaults?.host || 'neko';
|
const hostId = req.query.host || hostsConfig.defaults?.host || 'neko';
|
||||||
const host = hostsConfig.hosts[hostId];
|
const host = hostsConfig.hosts[hostId];
|
||||||
|
|
||||||
@@ -210,7 +235,7 @@ app.get('/api/health', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Browse directories on a host (for directory picker)
|
// Browse directories on a host (for directory picker)
|
||||||
app.get('/api/browse', async (req, res) => {
|
app.get('/api/browse', requireAuth, async (req, res) => {
|
||||||
const hostId = req.query.host || hostsConfig.defaults?.host || 'neko';
|
const hostId = req.query.host || hostsConfig.defaults?.host || 'neko';
|
||||||
const path = req.query.path || '~';
|
const path = req.query.path || '~';
|
||||||
const host = hostsConfig.hosts[hostId];
|
const host = hostsConfig.hosts[hostId];
|
||||||
@@ -304,7 +329,7 @@ app.get('/api/browse', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// File upload endpoint
|
// File upload endpoint
|
||||||
app.post('/api/upload/:sessionId', upload.array('files', 5), async (req, res) => {
|
app.post('/api/upload/:sessionId', requireAuth, upload.array('files', 5), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
if (!req.files || req.files.length === 0) {
|
if (!req.files || req.files.length === 0) {
|
||||||
return res.status(400).json({ error: 'No files uploaded' });
|
return res.status(400).json({ error: 'No files uploaded' });
|
||||||
@@ -443,12 +468,14 @@ function parseHistoryContent(content) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get session history for a project (supports SSH hosts)
|
// Get session history for a project (supports SSH hosts)
|
||||||
app.get('/api/history/:project', async (req, res) => {
|
app.get('/api/history/:project', requireAuth, async (req, res) => {
|
||||||
|
console.log(`[History] Request for project: ${req.params.project}, host: ${req.query.host}`);
|
||||||
try {
|
try {
|
||||||
const projectPath = decodeURIComponent(req.params.project);
|
const projectPath = decodeURIComponent(req.params.project);
|
||||||
const hostId = req.query.host;
|
const hostId = req.query.host;
|
||||||
const host = hostId ? hostsConfig.hosts[hostId] : null;
|
const host = hostId ? hostsConfig.hosts[hostId] : null;
|
||||||
const isSSH = host?.connection?.type === 'ssh';
|
const isSSH = host?.connection?.type === 'ssh';
|
||||||
|
console.log(`[History] Resolved - projectPath: ${projectPath}, hostId: ${hostId}, isSSH: ${isSSH}`);
|
||||||
|
|
||||||
// Convert project path to Claude's folder naming convention
|
// Convert project path to Claude's folder naming convention
|
||||||
const projectFolder = projectPath.replace(/\//g, '-');
|
const projectFolder = projectPath.replace(/\//g, '-');
|
||||||
@@ -459,8 +486,9 @@ app.get('/api/history/:project', async (req, res) => {
|
|||||||
const sshTarget = `${user}@${sshHost}`;
|
const sshTarget = `${user}@${sshHost}`;
|
||||||
const historyDir = `~/.claude/projects/${projectFolder}`;
|
const historyDir = `~/.claude/projects/${projectFolder}`;
|
||||||
|
|
||||||
// Find latest session file via SSH
|
// Find latest non-empty session file via SSH (skip agent files and empty files)
|
||||||
const findCmd = `ls -t ${historyDir}/*.jsonl 2>/dev/null | grep -v agent- | head -1`;
|
// Using a simpler approach: find non-empty files with find command
|
||||||
|
const findCmd = `find ${historyDir} -maxdepth 1 -name '*.jsonl' ! -name 'agent-*' -size +0 -printf '%T@ %p\\n' 2>/dev/null | sort -rn | head -1 | cut -d' ' -f2-`;
|
||||||
|
|
||||||
const { execSync } = await import('child_process');
|
const { execSync } = await import('child_process');
|
||||||
try {
|
try {
|
||||||
@@ -483,6 +511,7 @@ app.get('/api/history/:project', async (req, res) => {
|
|||||||
const sessionId = basename(latestFile).replace('.jsonl', '');
|
const sessionId = basename(latestFile).replace('.jsonl', '');
|
||||||
const messages = parseHistoryContent(content);
|
const messages = parseHistoryContent(content);
|
||||||
|
|
||||||
|
console.log(`[History] SSH - Returning ${messages.length} messages from session ${sessionId}`);
|
||||||
return res.json({ messages, sessionId, source: 'ssh' });
|
return res.json({ messages, sessionId, source: 'ssh' });
|
||||||
} catch (sshErr) {
|
} catch (sshErr) {
|
||||||
console.error('SSH history fetch error:', sshErr.message);
|
console.error('SSH history fetch error:', sshErr.message);
|
||||||
@@ -497,14 +526,16 @@ app.get('/api/history/:project', async (req, res) => {
|
|||||||
return res.json({ messages: [], sessionId: null });
|
return res.json({ messages: [], sessionId: null });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the most recent non-agent session file
|
// Find the most recent non-agent, non-empty session file
|
||||||
const files = readdirSync(historyDir)
|
const files = readdirSync(historyDir)
|
||||||
.filter(f => f.endsWith('.jsonl') && !f.startsWith('agent-'))
|
.filter(f => f.endsWith('.jsonl') && !f.startsWith('agent-'))
|
||||||
.map(f => ({
|
.map(f => ({
|
||||||
name: f,
|
name: f,
|
||||||
path: join(historyDir, f),
|
path: join(historyDir, f),
|
||||||
mtime: statSync(join(historyDir, f)).mtime
|
mtime: statSync(join(historyDir, f)).mtime,
|
||||||
|
size: statSync(join(historyDir, f)).size
|
||||||
}))
|
}))
|
||||||
|
.filter(f => f.size > 0) // Skip empty files
|
||||||
.sort((a, b) => b.mtime - a.mtime);
|
.sort((a, b) => b.mtime - a.mtime);
|
||||||
|
|
||||||
if (files.length === 0) {
|
if (files.length === 0) {
|
||||||
@@ -523,10 +554,27 @@ app.get('/api/history/:project', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
wss.on('connection', (ws, req) => {
|
} // End of registerApiRoutes function
|
||||||
|
|
||||||
|
wss.on('connection', async (ws, req) => {
|
||||||
const sessionId = uuidv4();
|
const sessionId = uuidv4();
|
||||||
console.log(`[${sessionId}] New WebSocket connection`);
|
console.log(`[${sessionId}] New WebSocket connection`);
|
||||||
|
|
||||||
|
// Authenticate WebSocket connection
|
||||||
|
let wsUser = null;
|
||||||
|
if (authConfig.app.authEnabled && sessionStore) {
|
||||||
|
wsUser = await authenticateWebSocket(req, sessionStore);
|
||||||
|
if (!wsUser) {
|
||||||
|
console.log(`[${sessionId}] WebSocket authentication failed - closing connection`);
|
||||||
|
ws.close(1008, 'Unauthorized');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(`[${sessionId}] WebSocket authenticated as: ${wsUser.email}`);
|
||||||
|
} else {
|
||||||
|
// Auth disabled - use anonymous user
|
||||||
|
wsUser = { id: 'anonymous', email: 'anonymous@local', groups: ['agent-admin'], isAdmin: true };
|
||||||
|
}
|
||||||
|
|
||||||
let claudeProcess = null;
|
let claudeProcess = null;
|
||||||
let currentProject = null;
|
let currentProject = null;
|
||||||
let currentHostId = null; // Track current host for restart
|
let currentHostId = null; // Track current host for restart
|
||||||
@@ -642,7 +690,7 @@ wss.on('connection', (ws, req) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
sessions.set(sessionId, { process: claudeProcess, project: projectPath, host: host, hostId: hostId });
|
sessions.set(sessionId, { process: claudeProcess, project: projectPath, host: host, hostId: hostId, user: wsUser });
|
||||||
|
|
||||||
sendToClient('session_started', {
|
sendToClient('session_started', {
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -785,7 +833,8 @@ wss.on('connection', (ws, req) => {
|
|||||||
// Handle stderr
|
// Handle stderr
|
||||||
claudeProcess.stderr.on('data', (data) => {
|
claudeProcess.stderr.on('data', (data) => {
|
||||||
const content = data.toString();
|
const content = data.toString();
|
||||||
if (DEBUG) console.log(`[${sessionId}] stderr:`, content);
|
// Always log stderr for SSH connections (exit code 255 debugging)
|
||||||
|
if (DEBUG || isSSH) console.log(`[${sessionId}] stderr:`, content);
|
||||||
sendToClient('stderr', { content });
|
sendToClient('stderr', { content });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -992,7 +1041,77 @@ wss.on('connection', (ws, req) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Initialize and start server
|
||||||
|
async function startServer() {
|
||||||
|
// Validate auth config
|
||||||
|
if (!validateConfig()) {
|
||||||
|
console.error('[Server] Auth configuration invalid');
|
||||||
|
if (authConfig.app.authEnabled) {
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize Redis and session store
|
||||||
|
if (authConfig.app.authEnabled) {
|
||||||
|
try {
|
||||||
|
console.log('[Server] Connecting to Redis...');
|
||||||
|
const redisClient = createClient({ url: authConfig.redis.url });
|
||||||
|
redisClient.on('error', err => console.error('[Redis] Error:', err));
|
||||||
|
await redisClient.connect();
|
||||||
|
console.log('[Server] Redis connected');
|
||||||
|
|
||||||
|
// Create session store
|
||||||
|
sessionStore = new RedisStore({ client: redisClient });
|
||||||
|
|
||||||
|
// Configure session middleware
|
||||||
|
app.use(session({
|
||||||
|
store: sessionStore,
|
||||||
|
name: authConfig.session.name,
|
||||||
|
secret: authConfig.session.secret,
|
||||||
|
resave: false,
|
||||||
|
saveUninitialized: false,
|
||||||
|
cookie: {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: authConfig.session.secure,
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: authConfig.session.maxAge,
|
||||||
|
domain: authConfig.session.domain,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Initialize OIDC client
|
||||||
|
console.log('[Server] Initializing OIDC...');
|
||||||
|
await initializeOIDC();
|
||||||
|
console.log('[Server] OIDC initialized');
|
||||||
|
|
||||||
|
// Mount auth routes
|
||||||
|
app.use('/auth', authRoutes);
|
||||||
|
console.log('[Server] Auth routes mounted at /auth');
|
||||||
|
|
||||||
|
// Register API routes (after session middleware is set up)
|
||||||
|
registerApiRoutes();
|
||||||
|
console.log('[Server] API routes registered');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Server] Failed to initialize auth:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('[Server] Authentication disabled');
|
||||||
|
// Register API routes (no session middleware needed when auth is disabled)
|
||||||
|
registerApiRoutes();
|
||||||
|
console.log('[Server] API routes registered');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start listening
|
||||||
server.listen(PORT, HOST, () => {
|
server.listen(PORT, HOST, () => {
|
||||||
console.log(`Claude Web UI Backend running on http://${HOST}:${PORT}`);
|
console.log(`Claude Web UI Backend running on http://${HOST}:${PORT}`);
|
||||||
console.log(`WebSocket available at ws://${HOST}:${PORT}`);
|
console.log(`WebSocket available at ws://${HOST}:${PORT}`);
|
||||||
|
console.log(`Authentication: ${authConfig.app.authEnabled ? 'ENABLED' : 'DISABLED'}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the server
|
||||||
|
startServer().catch(err => {
|
||||||
|
console.error('[Server] Fatal error:', err);
|
||||||
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|||||||
146
backend/utils/oidc.js
Normal file
146
backend/utils/oidc.js
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
// OIDC Client Wrapper using openid-client
|
||||||
|
|
||||||
|
import { Issuer, generators } from 'openid-client';
|
||||||
|
import { authConfig } from '../config/auth.js';
|
||||||
|
|
||||||
|
let oidcClient = null;
|
||||||
|
let issuer = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the OIDC client by discovering the issuer
|
||||||
|
*/
|
||||||
|
export async function initializeOIDC() {
|
||||||
|
if (!authConfig.app.authEnabled) {
|
||||||
|
console.log('[OIDC] Authentication disabled, skipping initialization');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`[OIDC] Discovering issuer: ${authConfig.oidc.issuer}`);
|
||||||
|
issuer = await Issuer.discover(authConfig.oidc.issuer);
|
||||||
|
console.log(`[OIDC] Discovered issuer: ${issuer.issuer}`);
|
||||||
|
|
||||||
|
oidcClient = new issuer.Client({
|
||||||
|
client_id: authConfig.oidc.clientId,
|
||||||
|
client_secret: authConfig.oidc.clientSecret,
|
||||||
|
redirect_uris: [authConfig.oidc.redirectUri],
|
||||||
|
response_types: ['code'],
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[OIDC] Client initialized successfully');
|
||||||
|
return oidcClient;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[OIDC] Failed to initialize client:', error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the initialized OIDC client
|
||||||
|
*/
|
||||||
|
export function getClient() {
|
||||||
|
if (!oidcClient) {
|
||||||
|
throw new Error('OIDC client not initialized. Call initializeOIDC() first.');
|
||||||
|
}
|
||||||
|
return oidcClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate authorization URL for login
|
||||||
|
*/
|
||||||
|
export function getAuthorizationUrl(state, nonce, codeVerifier) {
|
||||||
|
const client = getClient();
|
||||||
|
const codeChallenge = generators.codeChallenge(codeVerifier);
|
||||||
|
|
||||||
|
return client.authorizationUrl({
|
||||||
|
scope: authConfig.oidc.scopes.join(' '),
|
||||||
|
state,
|
||||||
|
nonce,
|
||||||
|
code_challenge: codeChallenge,
|
||||||
|
code_challenge_method: 'S256',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exchange authorization code for tokens
|
||||||
|
*/
|
||||||
|
export async function exchangeCode(code, codeVerifier, nonce) {
|
||||||
|
const client = getClient();
|
||||||
|
|
||||||
|
const tokenSet = await client.callback(
|
||||||
|
authConfig.oidc.redirectUri,
|
||||||
|
{ code },
|
||||||
|
{ code_verifier: codeVerifier, nonce }
|
||||||
|
);
|
||||||
|
|
||||||
|
return tokenSet;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate and decode ID token claims
|
||||||
|
*/
|
||||||
|
export function getIdTokenClaims(tokenSet) {
|
||||||
|
return tokenSet.claims();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user info from the userinfo endpoint
|
||||||
|
*/
|
||||||
|
export async function getUserInfo(accessToken) {
|
||||||
|
const client = getClient();
|
||||||
|
return await client.userinfo(accessToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh access token using refresh token
|
||||||
|
*/
|
||||||
|
export async function refreshTokens(refreshToken) {
|
||||||
|
const client = getClient();
|
||||||
|
return await client.refresh(refreshToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get end session URL for logout
|
||||||
|
*/
|
||||||
|
export function getEndSessionUrl(idTokenHint, postLogoutRedirectUri) {
|
||||||
|
const client = getClient();
|
||||||
|
return client.endSessionUrl({
|
||||||
|
id_token_hint: idTokenHint,
|
||||||
|
post_logout_redirect_uri: postLogoutRedirectUri,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate random state for CSRF protection
|
||||||
|
*/
|
||||||
|
export function generateState() {
|
||||||
|
return generators.state();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate random nonce for ID token validation
|
||||||
|
*/
|
||||||
|
export function generateNonce() {
|
||||||
|
return generators.nonce();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate code verifier for PKCE
|
||||||
|
*/
|
||||||
|
export function generateCodeVerifier() {
|
||||||
|
return generators.codeVerifier();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
initializeOIDC,
|
||||||
|
getClient,
|
||||||
|
getAuthorizationUrl,
|
||||||
|
exchangeCode,
|
||||||
|
getIdTokenClaims,
|
||||||
|
getUserInfo,
|
||||||
|
refreshTokens,
|
||||||
|
getEndSessionUrl,
|
||||||
|
generateState,
|
||||||
|
generateNonce,
|
||||||
|
generateCodeVerifier,
|
||||||
|
};
|
||||||
@@ -1,4 +1,32 @@
|
|||||||
services:
|
services:
|
||||||
|
# Netbird client for VPN access to Mochi and other hosts
|
||||||
|
netbird-client:
|
||||||
|
image: netbirdio/netbird:latest
|
||||||
|
container_name: claude-webui-netbird
|
||||||
|
restart: unless-stopped
|
||||||
|
hostname: claude-webui
|
||||||
|
cap_add:
|
||||||
|
- NET_ADMIN
|
||||||
|
- SYS_ADMIN
|
||||||
|
- SYS_RESOURCE
|
||||||
|
environment:
|
||||||
|
- NB_SETUP_KEY=${NETBIRD_SETUP_KEY}
|
||||||
|
- NB_MANAGEMENT_URL=https://gw.sneakercloud.de
|
||||||
|
volumes:
|
||||||
|
- netbird-data:/var/lib/netbird
|
||||||
|
|
||||||
|
# Redis for session storage (shares network with netbird for localhost access)
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: claude-webui-redis
|
||||||
|
restart: unless-stopped
|
||||||
|
network_mode: container:claude-webui-netbird
|
||||||
|
depends_on:
|
||||||
|
- netbird-client
|
||||||
|
volumes:
|
||||||
|
- redis-data:/data
|
||||||
|
command: redis-server --appendonly yes
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
build:
|
build:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
@@ -6,7 +34,11 @@ services:
|
|||||||
network: host
|
network: host
|
||||||
container_name: claude-webui-backend
|
container_name: claude-webui-backend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
network_mode: host
|
# Share network with netbird-client for VPN access
|
||||||
|
network_mode: container:claude-webui-netbird
|
||||||
|
depends_on:
|
||||||
|
- netbird-client
|
||||||
|
- redis
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
@@ -31,9 +63,23 @@ services:
|
|||||||
- /opt/stacks:/stacks:rw
|
- /opt/stacks:/stacks:rw
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- HOST=100.105.142.13
|
# Listen on all interfaces - NPM handles SSL termination
|
||||||
|
- HOST=0.0.0.0
|
||||||
- PORT=3001
|
- PORT=3001
|
||||||
- PATH=/home/node/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
- PATH=/home/node/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||||
|
# OIDC Authentication
|
||||||
|
- OIDC_ISSUER=${OIDC_ISSUER}
|
||||||
|
- OIDC_CLIENT_ID=${OIDC_CLIENT_ID}
|
||||||
|
- OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET}
|
||||||
|
- OIDC_REDIRECT_URI=${OIDC_REDIRECT_URI}
|
||||||
|
# Session
|
||||||
|
- SESSION_SECRET=${SESSION_SECRET}
|
||||||
|
- SESSION_DOMAIN=${SESSION_DOMAIN}
|
||||||
|
- SESSION_SECURE=${SESSION_SECURE}
|
||||||
|
- SESSION_MAX_AGE=${SESSION_MAX_AGE}
|
||||||
|
- REDIS_URL=redis://localhost:6379
|
||||||
|
- FRONTEND_URL=${FRONTEND_URL}
|
||||||
|
- AUTH_ENABLED=${AUTH_ENABLED}
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
build:
|
build:
|
||||||
@@ -41,17 +87,24 @@ services:
|
|||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
network: host
|
network: host
|
||||||
args:
|
args:
|
||||||
- VITE_WS_URL=ws://100.105.142.13:3001
|
# Production: Use domain with SSL via NPM
|
||||||
- VITE_API_URL=http://100.105.142.13:3001
|
# /ws is proxied to backend by frontend nginx
|
||||||
|
- VITE_WS_URL=wss://agents.sneakercloud.de/ws
|
||||||
|
- VITE_API_URL=https://agents.sneakercloud.de
|
||||||
container_name: claude-webui-frontend
|
container_name: claude-webui-frontend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
# Share network with netbird-client - Frontend reaches Backend via localhost
|
||||||
- "100.105.142.13:3000:80"
|
# NPM reaches Frontend via Netbird IP (100.105.153.111:80)
|
||||||
|
network_mode: container:claude-webui-netbird
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
networks:
|
|
||||||
- claude-webui
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
|
npm:
|
||||||
|
external: true
|
||||||
claude-webui:
|
claude-webui:
|
||||||
name: claude-webui
|
name: claude-webui
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
netbird-data:
|
||||||
|
redis-data:
|
||||||
|
|||||||
@@ -8,7 +8,46 @@ server {
|
|||||||
gzip on;
|
gzip on;
|
||||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
||||||
|
|
||||||
# SPA routing
|
# Backend API proxy (same network namespace via netbird-client)
|
||||||
|
# Using 127.0.0.1 instead of localhost to force IPv4 (avoids IPv6 connection issues)
|
||||||
|
# Note: proxy_pass without URI preserves URL encoding (important for paths with %2F)
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://127.0.0.1:3001;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
# Always HTTPS since NPM handles SSL termination (required for secure cookies)
|
||||||
|
proxy_set_header X-Forwarded-Proto https;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Auth routes proxy
|
||||||
|
location /auth/ {
|
||||||
|
proxy_pass http://127.0.0.1:3001/auth/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
# Always HTTPS since NPM handles SSL termination
|
||||||
|
proxy_set_header X-Forwarded-Proto https;
|
||||||
|
}
|
||||||
|
|
||||||
|
# WebSocket proxy for Claude sessions
|
||||||
|
location /ws {
|
||||||
|
proxy_pass http://127.0.0.1:3001;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
# Always HTTPS since NPM handles SSL termination
|
||||||
|
proxy_set_header X-Forwarded-Proto https;
|
||||||
|
proxy_read_timeout 86400;
|
||||||
|
proxy_send_timeout 86400;
|
||||||
|
}
|
||||||
|
|
||||||
|
# SPA routing for frontend
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|||||||
59
frontend/package-lock.json
generated
59
frontend/package-lock.json
generated
@@ -13,6 +13,7 @@
|
|||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-syntax-highlighter": "^16.1.0",
|
"react-syntax-highlighter": "^16.1.0",
|
||||||
|
"react-textarea-autosize": "^8.5.9",
|
||||||
"remark-gfm": "^4.0.1"
|
"remark-gfm": "^4.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -3535,6 +3536,22 @@
|
|||||||
"react": ">= 0.14.0"
|
"react": ">= 0.14.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-textarea-autosize": {
|
||||||
|
"version": "8.5.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.5.9.tgz",
|
||||||
|
"integrity": "sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A==",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.20.13",
|
||||||
|
"use-composed-ref": "^1.3.0",
|
||||||
|
"use-latest": "^1.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/read-cache": {
|
"node_modules/read-cache": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||||
@@ -4112,6 +4129,48 @@
|
|||||||
"browserslist": ">= 4.21.0"
|
"browserslist": ">= 4.21.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/use-composed-ref": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-composed-ref/-/use-composed-ref-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-djviaxuOOh7wkj0paeO1Q/4wMZ8Zrnag5H6yBvzN7AKKe8beOaED9SF5/ByLqsku8NP4zQqsvM2u3ew/tJK8/w==",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/use-isomorphic-layout-effect": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/use-latest": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-latest/-/use-latest-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-mhg3xdm9NaM8q+gLT8KryJPnRFOz1/5XPBhmDEVZK1webPzDjrPk7f/mbpeLqTgB9msytYWANxgALOCJKnLvcQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"use-isomorphic-layout-effect": "^1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/util-deprecate": {
|
"node_modules/util-deprecate": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-syntax-highlighter": "^16.1.0",
|
"react-syntax-highlighter": "^16.1.0",
|
||||||
|
"react-textarea-autosize": "^8.5.9",
|
||||||
"remark-gfm": "^4.0.1"
|
"remark-gfm": "^4.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { useState, useCallback, useEffect } from 'react';
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
|
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||||
import { SessionProvider, useSessionManager } from './contexts/SessionContext';
|
import { SessionProvider, useSessionManager } from './contexts/SessionContext';
|
||||||
import { Sidebar } from './components/Sidebar';
|
import { Sidebar } from './components/Sidebar';
|
||||||
import { TabBar } from './components/TabBar';
|
import { TabBar } from './components/TabBar';
|
||||||
import { ChatPanel } from './components/ChatPanel';
|
import { ChatPanel } from './components/ChatPanel';
|
||||||
import { SplitLayout } from './components/SplitLayout';
|
import { SplitLayout } from './components/SplitLayout';
|
||||||
import { Menu } from 'lucide-react';
|
import { LoginPage } from './components/LoginPage';
|
||||||
|
import { Menu, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
function AppContent() {
|
function AppContent() {
|
||||||
const {
|
const {
|
||||||
@@ -101,7 +103,30 @@ function AppContent() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function App() {
|
// Loading screen while checking auth
|
||||||
|
function LoadingScreen() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-dark-950 flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<Loader2 className="w-8 h-8 text-orange-500 animate-spin mx-auto mb-4" />
|
||||||
|
<p className="text-dark-400">Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth wrapper - shows login or main app
|
||||||
|
function AuthenticatedApp() {
|
||||||
|
const { isAuthenticated, loading } = useAuth();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <LoadingScreen />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return <LoginPage />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SessionProvider>
|
<SessionProvider>
|
||||||
<AppContent />
|
<AppContent />
|
||||||
@@ -109,4 +134,12 @@ function App() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<AuthProvider>
|
||||||
|
<AuthenticatedApp />
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useRef, useEffect, memo, useCallback } from 'react';
|
import { useState, useRef, useEffect, memo, useCallback } from 'react';
|
||||||
|
import TextareaAutosize from 'react-textarea-autosize';
|
||||||
import { Send, Square, Command, History, Paperclip, X, Image, FileText } from 'lucide-react';
|
import { Send, Square, Command, History, Paperclip, X, Image, FileText } from 'lucide-react';
|
||||||
|
|
||||||
// LocalStorage key for input history
|
// LocalStorage key for input history
|
||||||
@@ -212,6 +213,10 @@ export const ChatInput = memo(function ChatInput({ onSend, onStop, disabled, isP
|
|||||||
setHistoryIndex(-1);
|
setHistoryIndex(-1);
|
||||||
setSavedInput('');
|
setSavedInput('');
|
||||||
setUploadError(null);
|
setUploadError(null);
|
||||||
|
// Reset textarea height
|
||||||
|
if (textareaRef.current) {
|
||||||
|
textareaRef.current.style.height = 'auto';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -221,21 +226,6 @@ export const ChatInput = memo(function ChatInput({ onSend, onStop, disabled, isP
|
|||||||
textareaRef.current?.focus();
|
textareaRef.current?.focus();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle input changes for command detection (debounced check, not on every key)
|
|
||||||
const handleInput = useCallback(() => {
|
|
||||||
const message = getMessage();
|
|
||||||
if (message.startsWith('/')) {
|
|
||||||
const query = message.slice(1).toLowerCase();
|
|
||||||
const filtered = COMMANDS.filter(cmd =>
|
|
||||||
cmd.name.toLowerCase().startsWith(query)
|
|
||||||
);
|
|
||||||
setFilteredCommands(filtered);
|
|
||||||
setShowCommands(filtered.length > 0 && message.length > 0);
|
|
||||||
setSelectedIndex(0);
|
|
||||||
} else if (showCommands) {
|
|
||||||
setShowCommands(false);
|
|
||||||
}
|
|
||||||
}, [showCommands]);
|
|
||||||
|
|
||||||
const handleKeyDown = (e) => {
|
const handleKeyDown = (e) => {
|
||||||
// ESC to stop generation
|
// ESC to stop generation
|
||||||
@@ -429,15 +419,16 @@ export const ChatInput = memo(function ChatInput({ onSend, onStop, disabled, isP
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<textarea
|
<TextareaAutosize
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
defaultValue=""
|
defaultValue=""
|
||||||
onInput={handleInput}
|
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onPaste={handlePaste}
|
onPaste={handlePaste}
|
||||||
placeholder={isProcessing ? 'Type to send a follow-up message...' : placeholder}
|
placeholder={isProcessing ? 'Type to send a follow-up message...' : placeholder}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
rows={1}
|
minRows={2}
|
||||||
|
maxRows={8}
|
||||||
|
cacheMeasurements
|
||||||
className="w-full bg-dark-800 border border-dark-700 rounded-xl px-4 py-3 pr-12 text-dark-100 placeholder-dark-500 focus:outline-none focus:border-orange-500/50 focus:ring-1 focus:ring-orange-500/20 resize-none transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
className="w-full bg-dark-800 border border-dark-700 rounded-xl px-4 py-3 pr-12 text-dark-100 placeholder-dark-500 focus:outline-none focus:border-orange-500/50 focus:ring-1 focus:ring-orange-500/20 resize-none transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
/>
|
/>
|
||||||
<div className="absolute right-2 bottom-2 text-xs text-dark-600">
|
<div className="absolute right-2 bottom-2 text-xs text-dark-600">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { memo, useCallback, useMemo } from 'react';
|
import { memo, useCallback, useMemo, useRef } from 'react';
|
||||||
import { MessageList } from './MessageList';
|
import { MessageList } from './MessageList';
|
||||||
import { ChatInput } from './ChatInput';
|
import { ChatInput } from './ChatInput';
|
||||||
import { StatusBar } from './StatusBar';
|
import { StatusBar } from './StatusBar';
|
||||||
@@ -94,25 +94,36 @@ const ErrorBanner = memo(function ErrorBanner({ error, onClear }) {
|
|||||||
|
|
||||||
// Use a separate hook that memoizes everything to prevent unnecessary re-renders
|
// Use a separate hook that memoizes everything to prevent unnecessary re-renders
|
||||||
function useMemoizedSession(sessionId) {
|
function useMemoizedSession(sessionId) {
|
||||||
const manager = useSessionManager();
|
const {
|
||||||
const session = manager.sessions[sessionId];
|
sessions,
|
||||||
const messages = manager.sessionMessages[sessionId] || [];
|
sessionMessages,
|
||||||
|
startClaudeSession,
|
||||||
|
stopClaudeSession,
|
||||||
|
sendMessage,
|
||||||
|
stopGeneration,
|
||||||
|
clearMessages,
|
||||||
|
changePermissionMode,
|
||||||
|
respondToPermission,
|
||||||
|
} = useSessionManager();
|
||||||
|
|
||||||
|
const session = sessions[sessionId];
|
||||||
|
const messages = sessionMessages[sessionId] || [];
|
||||||
|
|
||||||
// Memoize the combined session object
|
// Memoize the combined session object
|
||||||
const sessionWithMessages = useMemo(() => {
|
const sessionWithMessages = useMemo(() => {
|
||||||
return session ? { ...session, messages } : null;
|
return session ? { ...session, messages } : null;
|
||||||
}, [session, messages]);
|
}, [session, messages]);
|
||||||
|
|
||||||
// Memoize all action functions
|
// Memoize all action functions - use individual functions as deps, not the whole manager
|
||||||
const actions = useMemo(() => ({
|
const actions = useMemo(() => ({
|
||||||
start: () => manager.startClaudeSession(sessionId),
|
start: () => startClaudeSession(sessionId),
|
||||||
stop: () => manager.stopClaudeSession(sessionId),
|
stop: () => stopClaudeSession(sessionId),
|
||||||
send: (msg, attachments) => manager.sendMessage(sessionId, msg, attachments),
|
send: (msg, attachments) => sendMessage(sessionId, msg, attachments),
|
||||||
stopGeneration: () => manager.stopGeneration(sessionId),
|
stopGeneration: () => stopGeneration(sessionId),
|
||||||
clearMessages: () => manager.clearMessages(sessionId),
|
clearMessages: () => clearMessages(sessionId),
|
||||||
changePermissionMode: (mode) => manager.changePermissionMode(sessionId, mode),
|
changePermissionMode: (mode) => changePermissionMode(sessionId, mode),
|
||||||
respondToPermission: (reqId, allow) => manager.respondToPermission(sessionId, reqId, allow),
|
respondToPermission: (reqId, allow) => respondToPermission(sessionId, reqId, allow),
|
||||||
}), [sessionId, manager]);
|
}), [sessionId, startClaudeSession, stopClaudeSession, sendMessage, stopGeneration, clearMessages, changePermissionMode, respondToPermission]);
|
||||||
|
|
||||||
return { session: sessionWithMessages, ...actions };
|
return { session: sessionWithMessages, ...actions };
|
||||||
}
|
}
|
||||||
@@ -134,11 +145,22 @@ export const ChatPanel = memo(function ChatPanel({ sessionId }) {
|
|||||||
// For now, errors auto-clear on next action
|
// For now, errors auto-clear on next action
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Use refs for callbacks to keep them stable across re-renders
|
||||||
|
const sendRef = useRef(send);
|
||||||
|
sendRef.current = send;
|
||||||
|
const stopGenerationRef = useRef(stopGeneration);
|
||||||
|
stopGenerationRef.current = stopGeneration;
|
||||||
|
|
||||||
|
// These callbacks never change identity, preventing ChatInput re-renders
|
||||||
const handleSendMessage = useCallback((message, attachments = []) => {
|
const handleSendMessage = useCallback((message, attachments = []) => {
|
||||||
if (message.trim() || attachments.length > 0) {
|
if (message.trim() || attachments.length > 0) {
|
||||||
send(message, attachments);
|
sendRef.current(message, attachments);
|
||||||
}
|
}
|
||||||
}, [send]);
|
}, []);
|
||||||
|
|
||||||
|
const handleStopGeneration = useCallback(() => {
|
||||||
|
stopGenerationRef.current();
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return (
|
return (
|
||||||
@@ -178,7 +200,7 @@ export const ChatPanel = memo(function ChatPanel({ sessionId }) {
|
|||||||
{/* Input - memoized props to prevent re-renders during streaming */}
|
{/* Input - memoized props to prevent re-renders during streaming */}
|
||||||
<MemoizedChatInput
|
<MemoizedChatInput
|
||||||
onSend={handleSendMessage}
|
onSend={handleSendMessage}
|
||||||
onStop={stopGeneration}
|
onStop={handleStopGeneration}
|
||||||
disabled={!session.active}
|
disabled={!session.active}
|
||||||
isProcessing={session.isProcessing}
|
isProcessing={session.isProcessing}
|
||||||
sessionId={session.claudeSessionId}
|
sessionId={session.claudeSessionId}
|
||||||
|
|||||||
113
frontend/src/components/LoginPage.jsx
Normal file
113
frontend/src/components/LoginPage.jsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
// Login Page - shows when user is not authenticated
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
import { LogIn, Shield, Loader2, AlertCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
export function LoginPage() {
|
||||||
|
const { login, loading, error, authEnabled } = useAuth();
|
||||||
|
|
||||||
|
// Check for error params in URL (from OIDC callback)
|
||||||
|
useEffect(() => {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const errorParam = params.get('error');
|
||||||
|
if (errorParam) {
|
||||||
|
console.error('[Login] Auth error:', errorParam);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Get error from URL if present
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const urlError = urlParams.get('error');
|
||||||
|
|
||||||
|
const getErrorMessage = (err) => {
|
||||||
|
switch (err) {
|
||||||
|
case 'invalid_state':
|
||||||
|
return 'Session expired. Please try again.';
|
||||||
|
case 'invalid_nonce':
|
||||||
|
return 'Security validation failed. Please try again.';
|
||||||
|
case 'no_access':
|
||||||
|
return 'You do not have access to this application. Contact your administrator.';
|
||||||
|
case 'auth_failed':
|
||||||
|
return 'Authentication failed. Please try again.';
|
||||||
|
default:
|
||||||
|
return err || 'An error occurred during login.';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayError = urlError || error;
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-dark-950 flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<Loader2 className="w-8 h-8 text-orange-500 animate-spin mx-auto mb-4" />
|
||||||
|
<p className="text-dark-400">Checking authentication...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-dark-950 flex items-center justify-center p-4">
|
||||||
|
<div className="max-w-md w-full">
|
||||||
|
{/* Logo/Header */}
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<div className="w-16 h-16 rounded-xl bg-gradient-to-br from-orange-500 to-orange-600 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<span className="text-white font-bold text-2xl">C</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-white mb-2">Claude Web UI</h1>
|
||||||
|
<p className="text-dark-400">Sign in to access Claude Code sessions</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Message */}
|
||||||
|
{displayError && (
|
||||||
|
<div className="mb-6 p-4 bg-red-500/10 border border-red-500/30 rounded-lg flex items-start gap-3">
|
||||||
|
<AlertCircle className="w-5 h-5 text-red-400 flex-shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="text-red-400 font-medium">Login Error</p>
|
||||||
|
<p className="text-red-400/80 text-sm mt-1">{getErrorMessage(displayError)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Login Card */}
|
||||||
|
<div className="bg-dark-900 border border-dark-800 rounded-xl p-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* SSO Info */}
|
||||||
|
<div className="flex items-center gap-3 p-4 bg-dark-800/50 rounded-lg">
|
||||||
|
<Shield className="w-8 h-8 text-orange-400" />
|
||||||
|
<div>
|
||||||
|
<p className="text-dark-200 font-medium">Single Sign-On</p>
|
||||||
|
<p className="text-dark-500 text-sm">Sign in with your organization account</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Login Button */}
|
||||||
|
<button
|
||||||
|
onClick={() => login('/')}
|
||||||
|
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-orange-600 hover:bg-orange-500 rounded-lg font-medium transition-colors text-white"
|
||||||
|
>
|
||||||
|
<LogIn className="w-5 h-5" />
|
||||||
|
Sign in with SSO
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="mt-6 pt-4 border-t border-dark-800">
|
||||||
|
<p className="text-dark-500 text-xs text-center">
|
||||||
|
Access restricted to authorized users with agent-admin or agent-users group membership.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Version */}
|
||||||
|
<p className="text-dark-600 text-xs text-center mt-6">
|
||||||
|
Claude Code Web UI v1.0
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LoginPage;
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { Play, Square, Trash2, FolderOpen, ChevronRight, ChevronDown, Settings, Server, Plus, X, Folder, ArrowUp, Loader2 } from 'lucide-react';
|
import { Play, Square, Trash2, FolderOpen, ChevronRight, ChevronDown, Settings, Server, Plus, X, Folder, ArrowUp, Loader2, LogOut, User, Shield } from 'lucide-react';
|
||||||
import { useSessionManager } from '../contexts/SessionContext';
|
import { useSessionManager } from '../contexts/SessionContext';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL || 'http://100.105.142.13:3001';
|
|
||||||
const RECENT_DIRS_KEY = 'claude-webui-recent-dirs';
|
const RECENT_DIRS_KEY = 'claude-webui-recent-dirs';
|
||||||
const MAX_RECENT_DIRS = 10;
|
const MAX_RECENT_DIRS = 10;
|
||||||
|
|
||||||
@@ -46,6 +46,8 @@ export function Sidebar({ open, onToggle }) {
|
|||||||
updateSessionConfig,
|
updateSessionConfig,
|
||||||
} = useSessionManager();
|
} = useSessionManager();
|
||||||
|
|
||||||
|
const { user, authEnabled, logout, isAdmin } = useAuth();
|
||||||
|
|
||||||
const [hosts, setHosts] = useState([]);
|
const [hosts, setHosts] = useState([]);
|
||||||
const [recentDirs, setRecentDirs] = useState([]);
|
const [recentDirs, setRecentDirs] = useState([]);
|
||||||
const [showBrowser, setShowBrowser] = useState(false);
|
const [showBrowser, setShowBrowser] = useState(false);
|
||||||
@@ -63,7 +65,7 @@ export function Sidebar({ open, onToggle }) {
|
|||||||
|
|
||||||
// Fetch hosts on mount
|
// Fetch hosts on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch(`${API_URL}/api/hosts`)
|
fetch('/api/hosts', { credentials: 'include' })
|
||||||
.then(res => res.json())
|
.then(res => res.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
setHosts(data.hosts || []);
|
setHosts(data.hosts || []);
|
||||||
@@ -107,7 +109,9 @@ export function Sidebar({ open, onToggle }) {
|
|||||||
setBrowserError(null);
|
setBrowserError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_URL}/api/browse?host=${currentHost}&path=${encodeURIComponent(path)}`);
|
const res = await fetch(`/api/browse?host=${currentHost}&path=${encodeURIComponent(path)}`, {
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
if (data.error) {
|
if (data.error) {
|
||||||
@@ -385,11 +389,53 @@ export function Sidebar({ open, onToggle }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* User & Footer */}
|
||||||
|
<div className="border-t border-dark-800">
|
||||||
|
{/* User info */}
|
||||||
|
{authEnabled && user && (
|
||||||
|
<div className="p-4 border-b border-dark-800">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{user.avatar ? (
|
||||||
|
<img
|
||||||
|
src={user.avatar}
|
||||||
|
alt={user.name || user.email}
|
||||||
|
className="w-8 h-8 rounded-full flex-shrink-0 object-cover"
|
||||||
|
onError={(e) => {
|
||||||
|
e.target.style.display = 'none';
|
||||||
|
e.target.nextSibling.style.display = 'flex';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<div
|
||||||
|
className="w-8 h-8 rounded-full bg-dark-700 flex items-center justify-center flex-shrink-0"
|
||||||
|
style={{ display: user.avatar ? 'none' : 'flex' }}
|
||||||
|
>
|
||||||
|
<User className="w-4 h-4 text-dark-400" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm text-dark-200 truncate">{user.name || user.email}</div>
|
||||||
|
<div className="flex items-center gap-1 text-xs text-dark-500">
|
||||||
|
{isAdmin && <Shield className="w-3 h-3 text-orange-400" />}
|
||||||
|
<span className="truncate">{user.email}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={logout}
|
||||||
|
className="p-2 hover:bg-dark-700 rounded-lg text-dark-400 hover:text-red-400 transition-colors"
|
||||||
|
title="Sign out"
|
||||||
|
>
|
||||||
|
<LogOut className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="p-4 border-t border-dark-800 text-xs text-dark-500">
|
<div className="p-4 text-xs text-dark-500">
|
||||||
<div>Claude Code Web UI</div>
|
<div>Claude Code Web UI</div>
|
||||||
<div>Multi-Session Mode</div>
|
<div>Multi-Session Mode</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Directory Browser Modal */}
|
{/* Directory Browser Modal */}
|
||||||
{showBrowser && (
|
{showBrowser && (
|
||||||
|
|||||||
@@ -2,3 +2,4 @@ export { Header } from './Header';
|
|||||||
export { Sidebar } from './Sidebar';
|
export { Sidebar } from './Sidebar';
|
||||||
export { MessageList } from './MessageList';
|
export { MessageList } from './MessageList';
|
||||||
export { ChatInput } from './ChatInput';
|
export { ChatInput } from './ChatInput';
|
||||||
|
export { LoginPage } from './LoginPage';
|
||||||
|
|||||||
197
frontend/src/contexts/AuthContext.jsx
Normal file
197
frontend/src/contexts/AuthContext.jsx
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
// Authentication Context - handles user auth state and OIDC flow
|
||||||
|
|
||||||
|
import { createContext, useContext, useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
|
|
||||||
|
const AuthContext = createContext(null);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }) {
|
||||||
|
const [user, setUser] = useState(null);
|
||||||
|
const [authEnabled, setAuthEnabled] = useState(true);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [expiresAt, setExpiresAt] = useState(null);
|
||||||
|
|
||||||
|
// Check auth status on mount
|
||||||
|
useEffect(() => {
|
||||||
|
checkAuthStatus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Set up token refresh interval
|
||||||
|
useEffect(() => {
|
||||||
|
console.log('[AuthContext] Token refresh effect - authEnabled:', authEnabled, 'user:', user?.email, 'expiresAt:', expiresAt);
|
||||||
|
if (!authEnabled || !user || !expiresAt) {
|
||||||
|
console.log('[AuthContext] Token refresh effect - skipping (missing data)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh 5 minutes before expiry
|
||||||
|
const refreshTime = expiresAt - Date.now() - 5 * 60 * 1000;
|
||||||
|
console.log('[AuthContext] Token refresh calculation:', {
|
||||||
|
expiresAt,
|
||||||
|
now: Date.now(),
|
||||||
|
refreshTime,
|
||||||
|
willRefreshIn: refreshTime > 0 ? `${Math.round(refreshTime / 1000)}s` : 'NOW'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (refreshTime <= 0) {
|
||||||
|
// Token already expired or about to, refresh now
|
||||||
|
console.log('[AuthContext] Token expired or about to, refreshing NOW');
|
||||||
|
refreshToken();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[AuthContext] Setting refresh timer for ${Math.round(refreshTime / 1000)}s`);
|
||||||
|
const timer = setTimeout(refreshToken, refreshTime);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [authEnabled, user, expiresAt]);
|
||||||
|
|
||||||
|
// Check current auth status
|
||||||
|
const checkAuthStatus = useCallback(async () => {
|
||||||
|
console.log('[AuthContext] checkAuthStatus called');
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const res = await fetch('/auth/user', {
|
||||||
|
credentials: 'include', // Send cookies
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[AuthContext] /auth/user response status:', res.status);
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
console.log('[AuthContext] /auth/user data:', JSON.stringify(data));
|
||||||
|
console.log('[AuthContext] Setting user:', data.user?.email);
|
||||||
|
console.log('[AuthContext] expiresAt from server:', data.expiresAt, 'Date.now():', Date.now());
|
||||||
|
setUser(data.user);
|
||||||
|
setAuthEnabled(data.authEnabled);
|
||||||
|
setExpiresAt(data.expiresAt || null);
|
||||||
|
setError(null);
|
||||||
|
} else if (res.status === 401) {
|
||||||
|
// Not authenticated
|
||||||
|
console.log('[AuthContext] Not authenticated (401)');
|
||||||
|
setUser(null);
|
||||||
|
// Still need to know if auth is enabled
|
||||||
|
const statusRes = await fetch('/auth/status', {
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
if (statusRes.ok) {
|
||||||
|
const statusData = await statusRes.json();
|
||||||
|
setAuthEnabled(statusData.authEnabled);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error('Failed to check auth status');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Auth] Status check failed:', err);
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Initiate login - redirect to backend login route
|
||||||
|
const login = useCallback((returnTo = '/') => {
|
||||||
|
window.location.href = `/auth/login?returnTo=${encodeURIComponent(returnTo)}`;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Logout
|
||||||
|
const logout = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/auth/logout', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setUser(null);
|
||||||
|
setExpiresAt(null);
|
||||||
|
|
||||||
|
// If there's an OIDC logout URL, redirect to it
|
||||||
|
if (data.logoutUrl) {
|
||||||
|
window.location.href = data.logoutUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Auth] Logout failed:', err);
|
||||||
|
// Even if logout fails, clear local state
|
||||||
|
setUser(null);
|
||||||
|
setExpiresAt(null);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Refresh token
|
||||||
|
const refreshToken = useCallback(async () => {
|
||||||
|
console.log('[AuthContext] refreshToken called');
|
||||||
|
try {
|
||||||
|
const res = await fetch('/auth/refresh', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[AuthContext] /auth/refresh response status:', res.status);
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
console.log('[AuthContext] Token refreshed, new expiresAt:', data.expiresAt);
|
||||||
|
setExpiresAt(data.expiresAt);
|
||||||
|
} else if (res.status === 401) {
|
||||||
|
// Refresh token missing or invalid - but don't log out the user
|
||||||
|
// The session cookie is still valid, just the OIDC token refresh failed
|
||||||
|
// User will be properly logged out when session expires and /auth/user returns 401
|
||||||
|
console.log('[AuthContext] Refresh returned 401 - token refresh unavailable (this is OK, session may still be valid)');
|
||||||
|
// Clear expiresAt to prevent repeated refresh attempts
|
||||||
|
setExpiresAt(null);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Auth] Token refresh failed:', err);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Derived state
|
||||||
|
const isAuthenticated = useMemo(() => {
|
||||||
|
// If auth is disabled, everyone is "authenticated"
|
||||||
|
if (!authEnabled) return true;
|
||||||
|
return !!user;
|
||||||
|
}, [authEnabled, user]);
|
||||||
|
|
||||||
|
const isAdmin = useMemo(() => {
|
||||||
|
return user?.isAdmin || false;
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
// Context value
|
||||||
|
const value = useMemo(() => ({
|
||||||
|
// State
|
||||||
|
user,
|
||||||
|
authEnabled,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
isAuthenticated,
|
||||||
|
isAdmin,
|
||||||
|
expiresAt,
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
refreshToken,
|
||||||
|
checkAuthStatus,
|
||||||
|
}), [
|
||||||
|
user, authEnabled, loading, error, isAuthenticated, isAdmin, expiresAt,
|
||||||
|
login, logout, refreshToken, checkAuthStatus,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useAuth must be used within AuthProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AuthContext;
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
import { createContext, useContext, useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
import { createContext, useContext, useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
||||||
|
|
||||||
const WS_URL = import.meta.env.VITE_WS_URL || 'ws://100.105.142.13:3001';
|
// Build WebSocket URL from current location
|
||||||
const API_URL = import.meta.env.VITE_API_URL || 'http://100.105.142.13:3001';
|
function getWsUrl() {
|
||||||
|
if (import.meta.env.VITE_WS_URL) return import.meta.env.VITE_WS_URL;
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
return `${protocol}//${window.location.host}/ws`;
|
||||||
|
}
|
||||||
|
|
||||||
const SESSIONS_STORAGE_KEY = 'claude-webui-sessions';
|
const SESSIONS_STORAGE_KEY = 'claude-webui-sessions';
|
||||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||||
|
|
||||||
@@ -64,6 +69,10 @@ export function SessionProvider({ children }) {
|
|||||||
// Current assistant message refs keyed by session ID
|
// Current assistant message refs keyed by session ID
|
||||||
const currentAssistantMessages = useRef({});
|
const currentAssistantMessages = useRef({});
|
||||||
|
|
||||||
|
// Ref to current sessions state (for stable callbacks)
|
||||||
|
const sessionsRef = useRef(sessions);
|
||||||
|
sessionsRef.current = sessions;
|
||||||
|
|
||||||
// Track if initial load is done (for auto-connecting restored sessions)
|
// Track if initial load is done (for auto-connecting restored sessions)
|
||||||
const initialLoadDone = useRef(false);
|
const initialLoadDone = useRef(false);
|
||||||
const sessionsToConnect = useRef([]);
|
const sessionsToConnect = useRef([]);
|
||||||
@@ -263,6 +272,26 @@ export function SessionProvider({ children }) {
|
|||||||
for (const toolMsg of toolUseBlocks) {
|
for (const toolMsg of toolUseBlocks) {
|
||||||
addMessage(sessionId, toolMsg);
|
addMessage(sessionId, toolMsg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract usage stats from message if present
|
||||||
|
const usage = message.usage;
|
||||||
|
if (usage) {
|
||||||
|
const inputTokens = (usage.input_tokens || 0) + (usage.cache_read_input_tokens || 0);
|
||||||
|
const outputTokens = usage.output_tokens || 0;
|
||||||
|
const cacheReadTokens = usage.cache_read_input_tokens || 0;
|
||||||
|
const cacheCreationTokens = usage.cache_creation_input_tokens || 0;
|
||||||
|
|
||||||
|
updateSession(sessionId, (session) => ({
|
||||||
|
stats: {
|
||||||
|
...(session.stats || {}),
|
||||||
|
inputTokens: (session.stats?.inputTokens || 0) + inputTokens,
|
||||||
|
outputTokens: (session.stats?.outputTokens || 0) + outputTokens,
|
||||||
|
cacheReadTokens: (session.stats?.cacheReadTokens || 0) + cacheReadTokens,
|
||||||
|
cacheCreationTokens: (session.stats?.cacheCreationTokens || 0) + cacheCreationTokens,
|
||||||
|
numTurns: (session.stats?.numTurns || 0) + 1,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -342,6 +371,7 @@ export function SessionProvider({ children }) {
|
|||||||
|
|
||||||
case 'result': {
|
case 'result': {
|
||||||
// Final result with stats
|
// Final result with stats
|
||||||
|
console.log(`[${sessionId}] Result event:`, JSON.stringify(event, null, 2));
|
||||||
const defaultStats = { totalCost: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0, numTurns: 0 };
|
const defaultStats = { totalCost: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0, numTurns: 0 };
|
||||||
updateSession(sessionId, (session) => ({
|
updateSession(sessionId, (session) => ({
|
||||||
isProcessing: false,
|
isProcessing: false,
|
||||||
@@ -542,7 +572,7 @@ export function SessionProvider({ children }) {
|
|||||||
const connectSession = useCallback((sessionId) => {
|
const connectSession = useCallback((sessionId) => {
|
||||||
if (wsRefs.current[sessionId]?.readyState === WebSocket.OPEN) return;
|
if (wsRefs.current[sessionId]?.readyState === WebSocket.OPEN) return;
|
||||||
|
|
||||||
const ws = new WebSocket(WS_URL);
|
const ws = new WebSocket(getWsUrl());
|
||||||
wsRefs.current[sessionId] = ws;
|
wsRefs.current[sessionId] = ws;
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
@@ -643,7 +673,7 @@ export function SessionProvider({ children }) {
|
|||||||
|
|
||||||
// Start Claude session
|
// Start Claude session
|
||||||
const startClaudeSession = useCallback(async (sessionId) => {
|
const startClaudeSession = useCallback(async (sessionId) => {
|
||||||
const session = sessions[sessionId];
|
const session = sessionsRef.current[sessionId];
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
|
|
||||||
const ws = wsRefs.current[sessionId];
|
const ws = wsRefs.current[sessionId];
|
||||||
@@ -659,7 +689,8 @@ export function SessionProvider({ children }) {
|
|||||||
if (session.resumeOnStart) {
|
if (session.resumeOnStart) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`${API_URL}/api/history/${encodeURIComponent(session.project)}?host=${session.host}`
|
`/api/history/${encodeURIComponent(session.project)}?host=${session.host}`,
|
||||||
|
{ credentials: 'include' }
|
||||||
);
|
);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.messages && Array.isArray(data.messages)) {
|
if (data.messages && Array.isArray(data.messages)) {
|
||||||
@@ -680,7 +711,7 @@ export function SessionProvider({ children }) {
|
|||||||
host: session.host,
|
host: session.host,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}, [sessions, connectSession, updateSession]);
|
}, [connectSession]);
|
||||||
|
|
||||||
// Stop Claude session
|
// Stop Claude session
|
||||||
const stopClaudeSession = useCallback((sessionId) => {
|
const stopClaudeSession = useCallback((sessionId) => {
|
||||||
@@ -693,7 +724,7 @@ export function SessionProvider({ children }) {
|
|||||||
|
|
||||||
// Send message to session
|
// Send message to session
|
||||||
const sendMessage = useCallback(async (sessionId, message, attachments = []) => {
|
const sendMessage = useCallback(async (sessionId, message, attachments = []) => {
|
||||||
const session = sessions[sessionId];
|
const session = sessionsRef.current[sessionId];
|
||||||
if (!session?.active) return;
|
if (!session?.active) return;
|
||||||
|
|
||||||
const ws = wsRefs.current[sessionId];
|
const ws = wsRefs.current[sessionId];
|
||||||
@@ -708,9 +739,10 @@ export function SessionProvider({ children }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_URL}/api/upload/${session.claudeSessionId}`, {
|
const res = await fetch(`/api/upload/${session.claudeSessionId}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData,
|
body: formData,
|
||||||
|
credentials: 'include',
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
uploadedFiles = data.files || [];
|
uploadedFiles = data.files || [];
|
||||||
@@ -743,7 +775,7 @@ export function SessionProvider({ children }) {
|
|||||||
type: 'user_message',
|
type: 'user_message',
|
||||||
message: finalMessage,
|
message: finalMessage,
|
||||||
}));
|
}));
|
||||||
}, [sessions, updateSession, addMessage]);
|
}, [updateSession, addMessage]);
|
||||||
|
|
||||||
// Stop generation
|
// Stop generation
|
||||||
const stopGeneration = useCallback((sessionId) => {
|
const stopGeneration = useCallback((sessionId) => {
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||||
|
|
||||||
const WS_URL = import.meta.env.VITE_WS_URL || 'ws://100.105.142.13:3001';
|
// Build WebSocket URL from current location
|
||||||
const API_URL = import.meta.env.VITE_API_URL || 'http://100.105.142.13:3001';
|
function getWsUrl() {
|
||||||
|
if (import.meta.env.VITE_WS_URL) return import.meta.env.VITE_WS_URL;
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
return `${protocol}//${window.location.host}/ws`;
|
||||||
|
}
|
||||||
|
|
||||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||||
|
|
||||||
export function useClaudeSession() {
|
export function useClaudeSession() {
|
||||||
@@ -49,7 +54,7 @@ export function useClaudeSession() {
|
|||||||
const connect = useCallback(() => {
|
const connect = useCallback(() => {
|
||||||
if (wsRef.current?.readyState === WebSocket.OPEN) return;
|
if (wsRef.current?.readyState === WebSocket.OPEN) return;
|
||||||
|
|
||||||
const ws = new WebSocket(WS_URL);
|
const ws = new WebSocket(getWsUrl());
|
||||||
wsRef.current = ws;
|
wsRef.current = ws;
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
@@ -350,7 +355,9 @@ export function useClaudeSession() {
|
|||||||
try {
|
try {
|
||||||
const encodedProject = encodeURIComponent(project);
|
const encodedProject = encodeURIComponent(project);
|
||||||
const hostParam = host ? `?host=${host}` : '';
|
const hostParam = host ? `?host=${host}` : '';
|
||||||
const response = await fetch(`${API_URL}/api/history/${encodedProject}${hostParam}`);
|
const response = await fetch(`/api/history/${encodedProject}${hostParam}`, {
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (data.messages && data.messages.length > 0) {
|
if (data.messages && data.messages.length > 0) {
|
||||||
@@ -397,9 +404,10 @@ export function useClaudeSession() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_URL}/api/upload/${sessionId}`, {
|
const response = await fetch(`/api/upload/${sessionId}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData
|
body: formData,
|
||||||
|
credentials: 'include',
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
Reference in New Issue
Block a user