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:
2025-12-18 06:07:22 +01:00
parent cfee1711dc
commit 1186cb1b5e
23 changed files with 2884 additions and 87 deletions

3
.env.example Normal file
View 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

View 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?

View 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>

View 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
View 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
View 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,
};

View File

@@ -13,6 +13,11 @@
"ws": "^8.14.2",
"cors": "^2.8.5",
"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
View 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;

View File

@@ -7,10 +7,32 @@ import cors from 'cors';
import { existsSync, readFileSync, readdirSync, statSync, mkdirSync, writeFileSync } from 'fs';
import { join, basename, extname } from 'path';
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();
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(cookieParser());
// Session store reference (set after Redis connection)
let sessionStore = null;
const PORT = process.env.PORT || 3001;
const HOST = process.env.HOST || '0.0.0.0';
@@ -137,8 +159,11 @@ function scanProjects(basePath, depth = 0, maxDepth = 1) {
return projects;
}
// REST endpoint to list hosts
app.get('/api/hosts', (req, res) => {
// Function to register API routes (called after session middleware is set up)
function registerApiRoutes() {
// REST endpoint to list hosts (protected)
app.get('/api/hosts', requireAuth, (req, res) => {
const hosts = Object.entries(hostsConfig.hosts).map(([id, host]) => ({
id,
name: host.name,
@@ -151,8 +176,8 @@ app.get('/api/hosts', (req, res) => {
res.json({ hosts, defaultHost: hostsConfig.defaults?.host || 'neko' });
});
// REST endpoint to list projects for a host
app.get('/api/projects', (req, res) => {
// REST endpoint to list projects for a host (protected)
app.get('/api/projects', requireAuth, (req, res) => {
const hostId = req.query.host || hostsConfig.defaults?.host || 'neko';
const host = hostsConfig.hosts[hostId];
@@ -210,7 +235,7 @@ app.get('/api/health', (req, res) => {
});
// 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 path = req.query.path || '~';
const host = hostsConfig.hosts[hostId];
@@ -304,7 +329,7 @@ app.get('/api/browse', async (req, res) => {
});
// 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 {
if (!req.files || req.files.length === 0) {
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)
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 {
const projectPath = decodeURIComponent(req.params.project);
const hostId = req.query.host;
const host = hostId ? hostsConfig.hosts[hostId] : null;
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
const projectFolder = projectPath.replace(/\//g, '-');
@@ -459,8 +486,9 @@ app.get('/api/history/:project', async (req, res) => {
const sshTarget = `${user}@${sshHost}`;
const historyDir = `~/.claude/projects/${projectFolder}`;
// Find latest session file via SSH
const findCmd = `ls -t ${historyDir}/*.jsonl 2>/dev/null | grep -v agent- | head -1`;
// Find latest non-empty session file via SSH (skip agent files and empty files)
// 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');
try {
@@ -483,6 +511,7 @@ app.get('/api/history/:project', async (req, res) => {
const sessionId = basename(latestFile).replace('.jsonl', '');
const messages = parseHistoryContent(content);
console.log(`[History] SSH - Returning ${messages.length} messages from session ${sessionId}`);
return res.json({ messages, sessionId, source: 'ssh' });
} catch (sshErr) {
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 });
}
// Find the most recent non-agent session file
// Find the most recent non-agent, non-empty session file
const files = readdirSync(historyDir)
.filter(f => f.endsWith('.jsonl') && !f.startsWith('agent-'))
.map(f => ({
name: 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);
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();
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 currentProject = null;
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', {
sessionId,
@@ -785,7 +833,8 @@ wss.on('connection', (ws, req) => {
// Handle stderr
claudeProcess.stderr.on('data', (data) => {
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 });
});
@@ -992,7 +1041,77 @@ wss.on('connection', (ws, req) => {
});
});
server.listen(PORT, HOST, () => {
// 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, () => {
console.log(`Claude Web UI Backend running on http://${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
View 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,
};

View File

@@ -1,4 +1,32 @@
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:
build:
context: ./backend
@@ -6,7 +34,11 @@ services:
network: host
container_name: claude-webui-backend
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:
resources:
limits:
@@ -31,9 +63,23 @@ services:
- /opt/stacks:/stacks:rw
environment:
- NODE_ENV=production
- HOST=100.105.142.13
# Listen on all interfaces - NPM handles SSL termination
- HOST=0.0.0.0
- PORT=3001
- 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:
build:
@@ -41,17 +87,24 @@ services:
dockerfile: Dockerfile
network: host
args:
- VITE_WS_URL=ws://100.105.142.13:3001
- VITE_API_URL=http://100.105.142.13:3001
# Production: Use domain with SSL via NPM
# /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
restart: unless-stopped
ports:
- "100.105.142.13:3000:80"
# Share network with netbird-client - Frontend reaches Backend via localhost
# NPM reaches Frontend via Netbird IP (100.105.153.111:80)
network_mode: container:claude-webui-netbird
depends_on:
- backend
networks:
- claude-webui
networks:
npm:
external: true
claude-webui:
name: claude-webui
volumes:
netbird-data:
redis-data:

View File

@@ -8,7 +8,46 @@ server {
gzip on;
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 / {
try_files $uri $uri/ /index.html;
}

View File

@@ -13,6 +13,7 @@
"react-dom": "^18.2.0",
"react-markdown": "^10.1.0",
"react-syntax-highlighter": "^16.1.0",
"react-textarea-autosize": "^8.5.9",
"remark-gfm": "^4.0.1"
},
"devDependencies": {
@@ -3535,6 +3536,22 @@
"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": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@@ -4112,6 +4129,48 @@
"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": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",

View File

@@ -14,6 +14,7 @@
"react-dom": "^18.2.0",
"react-markdown": "^10.1.0",
"react-syntax-highlighter": "^16.1.0",
"react-textarea-autosize": "^8.5.9",
"remark-gfm": "^4.0.1"
},
"devDependencies": {

View File

@@ -1,10 +1,12 @@
import { useState, useCallback, useEffect } from 'react';
import { AuthProvider, useAuth } from './contexts/AuthContext';
import { SessionProvider, useSessionManager } from './contexts/SessionContext';
import { Sidebar } from './components/Sidebar';
import { TabBar } from './components/TabBar';
import { ChatPanel } from './components/ChatPanel';
import { SplitLayout } from './components/SplitLayout';
import { Menu } from 'lucide-react';
import { LoginPage } from './components/LoginPage';
import { Menu, Loader2 } from 'lucide-react';
function AppContent() {
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 (
<SessionProvider>
<AppContent />
@@ -109,4 +134,12 @@ function App() {
);
}
function App() {
return (
<AuthProvider>
<AuthenticatedApp />
</AuthProvider>
);
}
export default App;

View File

@@ -1,4 +1,5 @@
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';
// LocalStorage key for input history
@@ -212,6 +213,10 @@ export const ChatInput = memo(function ChatInput({ onSend, onStop, disabled, isP
setHistoryIndex(-1);
setSavedInput('');
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();
};
// 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) => {
// ESC to stop generation
@@ -429,15 +419,16 @@ export const ChatInput = memo(function ChatInput({ onSend, onStop, disabled, isP
</div>
)}
<textarea
<TextareaAutosize
ref={textareaRef}
defaultValue=""
onInput={handleInput}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder={isProcessing ? 'Type to send a follow-up message...' : placeholder}
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"
/>
<div className="absolute right-2 bottom-2 text-xs text-dark-600">

View File

@@ -1,4 +1,4 @@
import { memo, useCallback, useMemo } from 'react';
import { memo, useCallback, useMemo, useRef } from 'react';
import { MessageList } from './MessageList';
import { ChatInput } from './ChatInput';
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
function useMemoizedSession(sessionId) {
const manager = useSessionManager();
const session = manager.sessions[sessionId];
const messages = manager.sessionMessages[sessionId] || [];
const {
sessions,
sessionMessages,
startClaudeSession,
stopClaudeSession,
sendMessage,
stopGeneration,
clearMessages,
changePermissionMode,
respondToPermission,
} = useSessionManager();
const session = sessions[sessionId];
const messages = sessionMessages[sessionId] || [];
// Memoize the combined session object
const sessionWithMessages = useMemo(() => {
return session ? { ...session, messages } : null;
}, [session, messages]);
// Memoize all action functions
// Memoize all action functions - use individual functions as deps, not the whole manager
const actions = useMemo(() => ({
start: () => manager.startClaudeSession(sessionId),
stop: () => manager.stopClaudeSession(sessionId),
send: (msg, attachments) => manager.sendMessage(sessionId, msg, attachments),
stopGeneration: () => manager.stopGeneration(sessionId),
clearMessages: () => manager.clearMessages(sessionId),
changePermissionMode: (mode) => manager.changePermissionMode(sessionId, mode),
respondToPermission: (reqId, allow) => manager.respondToPermission(sessionId, reqId, allow),
}), [sessionId, manager]);
start: () => startClaudeSession(sessionId),
stop: () => stopClaudeSession(sessionId),
send: (msg, attachments) => sendMessage(sessionId, msg, attachments),
stopGeneration: () => stopGeneration(sessionId),
clearMessages: () => clearMessages(sessionId),
changePermissionMode: (mode) => changePermissionMode(sessionId, mode),
respondToPermission: (reqId, allow) => respondToPermission(sessionId, reqId, allow),
}), [sessionId, startClaudeSession, stopClaudeSession, sendMessage, stopGeneration, clearMessages, changePermissionMode, respondToPermission]);
return { session: sessionWithMessages, ...actions };
}
@@ -134,11 +145,22 @@ export const ChatPanel = memo(function ChatPanel({ sessionId }) {
// 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 = []) => {
if (message.trim() || attachments.length > 0) {
send(message, attachments);
sendRef.current(message, attachments);
}
}, [send]);
}, []);
const handleStopGeneration = useCallback(() => {
stopGenerationRef.current();
}, []);
if (!session) {
return (
@@ -178,7 +200,7 @@ export const ChatPanel = memo(function ChatPanel({ sessionId }) {
{/* Input - memoized props to prevent re-renders during streaming */}
<MemoizedChatInput
onSend={handleSendMessage}
onStop={stopGeneration}
onStop={handleStopGeneration}
disabled={!session.active}
isProcessing={session.isProcessing}
sessionId={session.claudeSessionId}

View 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;

View File

@@ -1,8 +1,8 @@
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 { 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 MAX_RECENT_DIRS = 10;
@@ -46,6 +46,8 @@ export function Sidebar({ open, onToggle }) {
updateSessionConfig,
} = useSessionManager();
const { user, authEnabled, logout, isAdmin } = useAuth();
const [hosts, setHosts] = useState([]);
const [recentDirs, setRecentDirs] = useState([]);
const [showBrowser, setShowBrowser] = useState(false);
@@ -63,7 +65,7 @@ export function Sidebar({ open, onToggle }) {
// Fetch hosts on mount
useEffect(() => {
fetch(`${API_URL}/api/hosts`)
fetch('/api/hosts', { credentials: 'include' })
.then(res => res.json())
.then(data => {
setHosts(data.hosts || []);
@@ -107,7 +109,9 @@ export function Sidebar({ open, onToggle }) {
setBrowserError(null);
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();
if (data.error) {
@@ -385,11 +389,53 @@ export function Sidebar({ open, onToggle }) {
</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 */}
<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>Multi-Session Mode</div>
</div>
</div>
{/* Directory Browser Modal */}
{showBrowser && (

View File

@@ -2,3 +2,4 @@ export { Header } from './Header';
export { Sidebar } from './Sidebar';
export { MessageList } from './MessageList';
export { ChatInput } from './ChatInput';
export { LoginPage } from './LoginPage';

View 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;

View File

@@ -1,7 +1,12 @@
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';
const API_URL = import.meta.env.VITE_API_URL || 'http://100.105.142.13:3001';
// Build WebSocket URL from current location
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 MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
@@ -64,6 +69,10 @@ export function SessionProvider({ children }) {
// Current assistant message refs keyed by session ID
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)
const initialLoadDone = useRef(false);
const sessionsToConnect = useRef([]);
@@ -263,6 +272,26 @@ export function SessionProvider({ children }) {
for (const toolMsg of toolUseBlocks) {
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;
}
@@ -342,6 +371,7 @@ export function SessionProvider({ children }) {
case 'result': {
// 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 };
updateSession(sessionId, (session) => ({
isProcessing: false,
@@ -542,7 +572,7 @@ export function SessionProvider({ children }) {
const connectSession = useCallback((sessionId) => {
if (wsRefs.current[sessionId]?.readyState === WebSocket.OPEN) return;
const ws = new WebSocket(WS_URL);
const ws = new WebSocket(getWsUrl());
wsRefs.current[sessionId] = ws;
ws.onopen = () => {
@@ -643,7 +673,7 @@ export function SessionProvider({ children }) {
// Start Claude session
const startClaudeSession = useCallback(async (sessionId) => {
const session = sessions[sessionId];
const session = sessionsRef.current[sessionId];
if (!session) return;
const ws = wsRefs.current[sessionId];
@@ -659,7 +689,8 @@ export function SessionProvider({ children }) {
if (session.resumeOnStart) {
try {
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();
if (data.messages && Array.isArray(data.messages)) {
@@ -680,7 +711,7 @@ export function SessionProvider({ children }) {
host: session.host,
}));
}
}, [sessions, connectSession, updateSession]);
}, [connectSession]);
// Stop Claude session
const stopClaudeSession = useCallback((sessionId) => {
@@ -693,7 +724,7 @@ export function SessionProvider({ children }) {
// Send message to session
const sendMessage = useCallback(async (sessionId, message, attachments = []) => {
const session = sessions[sessionId];
const session = sessionsRef.current[sessionId];
if (!session?.active) return;
const ws = wsRefs.current[sessionId];
@@ -708,9 +739,10 @@ export function SessionProvider({ children }) {
}
try {
const res = await fetch(`${API_URL}/api/upload/${session.claudeSessionId}`, {
const res = await fetch(`/api/upload/${session.claudeSessionId}`, {
method: 'POST',
body: formData,
credentials: 'include',
});
const data = await res.json();
uploadedFiles = data.files || [];
@@ -743,7 +775,7 @@ export function SessionProvider({ children }) {
type: 'user_message',
message: finalMessage,
}));
}, [sessions, updateSession, addMessage]);
}, [updateSession, addMessage]);
// Stop generation
const stopGeneration = useCallback((sessionId) => {

View File

@@ -1,7 +1,12 @@
import { useState, useRef, useCallback, useEffect } from 'react';
const WS_URL = import.meta.env.VITE_WS_URL || 'ws://100.105.142.13:3001';
const API_URL = import.meta.env.VITE_API_URL || 'http://100.105.142.13:3001';
// Build WebSocket URL from current location
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
export function useClaudeSession() {
@@ -49,7 +54,7 @@ export function useClaudeSession() {
const connect = useCallback(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) return;
const ws = new WebSocket(WS_URL);
const ws = new WebSocket(getWsUrl());
wsRef.current = ws;
ws.onopen = () => {
@@ -350,7 +355,9 @@ export function useClaudeSession() {
try {
const encodedProject = encodeURIComponent(project);
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) {
const data = await response.json();
if (data.messages && data.messages.length > 0) {
@@ -397,9 +404,10 @@ export function useClaudeSession() {
}
try {
const response = await fetch(`${API_URL}/api/upload/${sessionId}`, {
const response = await fetch(`/api/upload/${sessionId}`, {
method: 'POST',
body: formData
body: formData,
credentials: 'include',
});
if (!response.ok) {