feat: Add OIDC authentication with Authentik integration
- Add OIDC login flow with Authentik provider - Implement session-based auth with Redis store - Add avatar display from OIDC claims - Fix input field performance with react-textarea-autosize - Stabilize callbacks to prevent unnecessary re-renders - Fix history loading to skip empty session files - Add 2-row default height for input textarea 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
3
.env.example
Normal file
3
.env.example
Normal file
@@ -0,0 +1,3 @@
|
||||
# Netbird Setup Key from https://gw.sneakercloud.de
|
||||
# Create a new setup key for "claude-webui" with appropriate permissions
|
||||
NETBIRD_SETUP_KEY=your-setup-key-here
|
||||
402
.prompts/001-oidc-auth-plan/SUMMARY.md
Normal file
402
.prompts/001-oidc-auth-plan/SUMMARY.md
Normal file
@@ -0,0 +1,402 @@
|
||||
# OIDC Authentication Implementation - Executive Summary
|
||||
|
||||
## Overview
|
||||
|
||||
This plan details the complete implementation of OIDC authentication for Claude Web UI using Authentik as the identity provider. The implementation adds secure, production-ready authentication with group-based access control while preserving the existing WebSocket architecture.
|
||||
|
||||
## Current State
|
||||
|
||||
**Backend:**
|
||||
- Single-file Express server (`backend/server.js`, 999 lines)
|
||||
- WebSocket-based Claude Code CLI interface
|
||||
- No authentication or authorization
|
||||
- In-memory session storage
|
||||
|
||||
**Frontend:**
|
||||
- React application with SessionContext for state management
|
||||
- Direct WebSocket connections
|
||||
- localStorage for session persistence
|
||||
- No authentication UI
|
||||
|
||||
## Target State
|
||||
|
||||
**Authentication:**
|
||||
- OIDC provider: Authentik (auth.sneakercloud.de)
|
||||
- Authorization flow: Authorization Code with PKCE
|
||||
- Token storage: httpOnly cookies (XSS-safe)
|
||||
- Session backend: Redis (persistent, scalable)
|
||||
|
||||
**Authorization:**
|
||||
- Groups: `agent-admin` (full access), `agent-users` (standard access)
|
||||
- Protected REST endpoints
|
||||
- Authenticated WebSocket connections
|
||||
- User-isolated sessions
|
||||
|
||||
## Implementation Approach
|
||||
|
||||
### 5 Phases, 11-16 Days
|
||||
|
||||
#### Phase 1: Foundation (2-3 days)
|
||||
- Configure Authentik OIDC Provider/Application
|
||||
- Refactor monolithic backend into modules
|
||||
- Add dependencies (express-session, openid-client, Redis)
|
||||
- Implement Redis session store
|
||||
|
||||
#### Phase 2: Authentication Flow (2-3 days)
|
||||
- Implement OIDC client wrapper
|
||||
- Create auth routes (login, callback, logout)
|
||||
- Configure secure session cookies
|
||||
- Handle token refresh
|
||||
|
||||
#### Phase 3: API Protection (2-3 days)
|
||||
- Create authentication middleware
|
||||
- Protect all REST endpoints
|
||||
- Secure WebSocket connections
|
||||
- Associate sessions with users
|
||||
|
||||
#### Phase 4: Frontend UI (3-4 days)
|
||||
- Create AuthContext for authentication state
|
||||
- Build login page component
|
||||
- Add protected route wrapper
|
||||
- Integrate with existing SessionContext
|
||||
- Add user menu/logout
|
||||
|
||||
#### Phase 5: Production Hardening (2-3 days)
|
||||
- Security enhancements (CSRF, rate limiting, CSP)
|
||||
- Logging and monitoring
|
||||
- Error handling and edge cases
|
||||
- Documentation
|
||||
- Testing
|
||||
|
||||
## Key Technical Decisions
|
||||
|
||||
### 1. Backend Refactoring
|
||||
**Decision:** Split monolithic `server.js` into modular structure
|
||||
|
||||
**Rationale:**
|
||||
- Improves maintainability
|
||||
- Separates concerns
|
||||
- Easier testing
|
||||
- Supports future growth
|
||||
|
||||
**Structure:**
|
||||
```
|
||||
backend/
|
||||
├── server.js # Entry point
|
||||
├── config/auth.js # Auth configuration
|
||||
├── middleware/ # Auth & session middleware
|
||||
├── routes/ # API, auth, WebSocket routes
|
||||
└── utils/ # OIDC client wrapper
|
||||
```
|
||||
|
||||
### 2. Session Storage
|
||||
**Decision:** Redis with express-session
|
||||
|
||||
**Rationale:**
|
||||
- Persistent across restarts
|
||||
- Scalable to multiple backend instances
|
||||
- Fast session lookups (<10ms)
|
||||
- Industry standard
|
||||
|
||||
**Alternative Considered:** In-memory sessions
|
||||
- **Rejected:** Lost on restart, not scalable
|
||||
|
||||
### 3. Token Storage
|
||||
**Decision:** httpOnly cookies with encrypted tokens in Redis
|
||||
|
||||
**Rationale:**
|
||||
- httpOnly prevents XSS attacks
|
||||
- Secure flag ensures HTTPS-only
|
||||
- sameSite=lax prevents CSRF
|
||||
- Encryption at rest protects Redis compromise
|
||||
|
||||
**Alternative Considered:** localStorage
|
||||
- **Rejected:** Vulnerable to XSS
|
||||
|
||||
### 4. WebSocket Authentication
|
||||
**Decision:** Cookie-based session validation on upgrade
|
||||
|
||||
**Rationale:**
|
||||
- Cookies automatically included in upgrade request
|
||||
- Consistent with REST API auth
|
||||
- No need for separate token mechanism
|
||||
|
||||
**Implementation:**
|
||||
- Parse cookies from upgrade request headers
|
||||
- Load session from Redis
|
||||
- Validate user before accepting connection
|
||||
- Reject upgrade with 1008 code if unauthorized
|
||||
|
||||
### 5. OIDC Library
|
||||
**Decision:** `openid-client` (certified OIDC client)
|
||||
|
||||
**Rationale:**
|
||||
- Official OpenID Foundation certified
|
||||
- Actively maintained
|
||||
- Built-in PKCE support
|
||||
- Automatic discovery
|
||||
|
||||
**Alternative Considered:** `passport-openidconnect`
|
||||
- **Rejected:** More complex, passport overhead not needed
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Authentication
|
||||
- Authorization Code flow with PKCE
|
||||
- State parameter for CSRF protection
|
||||
- Nonce validation in ID token
|
||||
- Token signature verification
|
||||
- HTTPS-only in production
|
||||
|
||||
### Session Management
|
||||
- httpOnly cookies (no JavaScript access)
|
||||
- Secure flag (HTTPS-only)
|
||||
- sameSite=lax (CSRF protection)
|
||||
- 24-hour expiry with refresh
|
||||
- Encrypted tokens at rest
|
||||
|
||||
### API Protection
|
||||
- All endpoints require authentication
|
||||
- Group-based authorization
|
||||
- Rate limiting on auth endpoints
|
||||
- Origin validation on WebSocket upgrade
|
||||
- Content Security Policy headers
|
||||
|
||||
### Token Handling
|
||||
- Access tokens encrypted in Redis
|
||||
- Refresh tokens encrypted in Redis
|
||||
- Auto-refresh 5 minutes before expiry
|
||||
- Tokens cleared on logout
|
||||
- No tokens in logs
|
||||
|
||||
## Group-Based Access Control
|
||||
|
||||
### agent-users (Standard Access)
|
||||
- View hosts and projects
|
||||
- Create and manage own Claude sessions
|
||||
- Upload files to own sessions
|
||||
- View own session history
|
||||
- Standard WebSocket access
|
||||
|
||||
### agent-admin (Full Access)
|
||||
- All `agent-users` permissions
|
||||
- View all users' sessions (future)
|
||||
- Access admin endpoints (future)
|
||||
- Modify system configuration (future)
|
||||
|
||||
**Note:** Initial implementation focuses on authentication and basic group enforcement. Fine-grained permissions can be expanded post-MVP.
|
||||
|
||||
## Integration Points
|
||||
|
||||
### Authentik Configuration
|
||||
```yaml
|
||||
Provider:
|
||||
Name: Claude Web UI
|
||||
Type: OAuth2/OIDC
|
||||
Client Type: Confidential
|
||||
Flow: Authorization Code
|
||||
Scopes: openid, profile, email, groups
|
||||
|
||||
Application:
|
||||
Name: Claude Web UI
|
||||
Provider: [linked]
|
||||
Launch URL: https://agents.sneakercloud.de
|
||||
Redirect URI: https://agents.sneakercloud.de/auth/callback
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
```bash
|
||||
# OIDC
|
||||
OIDC_ISSUER=https://auth.sneakercloud.de/application/o/claude-web-ui/
|
||||
OIDC_CLIENT_ID=<from_authentik>
|
||||
OIDC_CLIENT_SECRET=<from_authentik>
|
||||
OIDC_REDIRECT_URI=https://agents.sneakercloud.de/auth/callback
|
||||
|
||||
# Session
|
||||
SESSION_SECRET=<generate_32_bytes>
|
||||
SESSION_MAX_AGE=86400000 # 24 hours
|
||||
REDIS_URL=redis://redis:6379
|
||||
|
||||
# App
|
||||
NODE_ENV=production
|
||||
FRONTEND_URL=https://agents.sneakercloud.de
|
||||
```
|
||||
|
||||
### Docker Compose Updates
|
||||
- Add Redis service
|
||||
- Mount Redis volume for persistence
|
||||
- Add environment variables
|
||||
- Configure service dependencies
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
### High Risk
|
||||
**WebSocket Authentication Complexity**
|
||||
- Mitigation: Thorough testing, fallback to polling
|
||||
- Contingency: Feature flag to disable auth temporarily
|
||||
|
||||
**Authentik Downtime**
|
||||
- Mitigation: Session caching, graceful degradation
|
||||
- Contingency: Allow continued use of valid sessions
|
||||
|
||||
**Session Store Failure (Redis)**
|
||||
- Mitigation: Redis persistence, backup strategy
|
||||
- Contingency: Fallback to in-memory (degraded mode)
|
||||
|
||||
### Medium Risk
|
||||
**Token Refresh Failures**
|
||||
- Mitigation: Retry logic, clear error messages
|
||||
- Contingency: Force re-login
|
||||
|
||||
**CORS/Cookie Issues**
|
||||
- Mitigation: Proper domain configuration, testing
|
||||
- Contingency: Documented troubleshooting steps
|
||||
|
||||
### Low Risk
|
||||
**Group Mapping Errors**
|
||||
- Mitigation: Default to `agent-users` if no groups
|
||||
- Contingency: Manual group assignment in Authentik
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
- OIDC client wrapper
|
||||
- Authentication middleware
|
||||
- Session management functions
|
||||
|
||||
### Integration Tests
|
||||
- Full login flow
|
||||
- Token exchange
|
||||
- Session persistence
|
||||
- WebSocket authentication
|
||||
|
||||
### Manual Tests
|
||||
- Login/logout flow
|
||||
- Session refresh
|
||||
- Token expiry handling
|
||||
- Group-based access
|
||||
- Multiple concurrent users
|
||||
- Admin vs user permissions
|
||||
|
||||
### Security Tests
|
||||
- XSS prevention (httpOnly cookies)
|
||||
- CSRF protection
|
||||
- Unauthorized API access
|
||||
- Session hijacking attempts
|
||||
- Token replay attacks
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
**Feature Flag:** `AUTH_ENABLED` environment variable
|
||||
|
||||
**Rollback Steps:**
|
||||
1. Set `AUTH_ENABLED=false`
|
||||
2. Restart backend service
|
||||
3. Authentication middleware skipped
|
||||
4. Frontend shows app without login
|
||||
5. Investigate and fix issues
|
||||
6. Re-enable with `AUTH_ENABLED=true`
|
||||
|
||||
**Data Preservation:**
|
||||
- Sessions stored in Redis persist
|
||||
- User data not lost
|
||||
- Can re-enable seamlessly
|
||||
|
||||
## Success Criteria
|
||||
|
||||
### Functional
|
||||
- [ ] Unauthenticated users cannot access app
|
||||
- [ ] Login redirects to Authentik and back successfully
|
||||
- [ ] WebSocket connections authenticated
|
||||
- [ ] Sessions persist across browser refresh
|
||||
- [ ] Logout clears session completely
|
||||
- [ ] Group-based access control enforced
|
||||
|
||||
### Performance
|
||||
- [ ] Auth middleware overhead <100ms
|
||||
- [ ] Session lookup <10ms
|
||||
- [ ] No WebSocket latency impact
|
||||
- [ ] Token refresh transparent to user
|
||||
|
||||
### Security
|
||||
- [ ] Zero XSS vulnerabilities
|
||||
- [ ] Zero unauthorized access
|
||||
- [ ] Tokens encrypted at rest
|
||||
- [ ] HTTPS-only in production
|
||||
- [ ] Rate limiting effective
|
||||
|
||||
### User Experience
|
||||
- [ ] Login flow <5 seconds
|
||||
- [ ] Clear error messages
|
||||
- [ ] No unnecessary re-authentication
|
||||
- [ ] Seamless session refresh
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
**Post-MVP Features:**
|
||||
- Multi-factor authentication via Authentik
|
||||
- API keys for CLI/programmatic access
|
||||
- Session management UI (view/revoke sessions)
|
||||
- Audit log for all actions
|
||||
- Fine-grained RBAC expansion
|
||||
- User preferences/settings storage
|
||||
- SSO with other internal services
|
||||
|
||||
## Dependencies
|
||||
|
||||
### NPM Packages
|
||||
- `express-session@^1.18.0` - Session management
|
||||
- `connect-redis@^7.1.0` - Redis session store
|
||||
- `redis@^4.6.0` - Redis client
|
||||
- `openid-client@^5.6.0` - OIDC client
|
||||
- `cookie-parser@^1.4.6` - Cookie parsing
|
||||
|
||||
### Infrastructure
|
||||
- Redis (session storage)
|
||||
- Authentik (identity provider)
|
||||
- Docker (containerization)
|
||||
|
||||
### External Services
|
||||
- Authentik @ auth.sneakercloud.de
|
||||
- Redis instance (new or existing)
|
||||
|
||||
## Documentation Deliverables
|
||||
|
||||
1. **AUTHENTICATION.md** - System overview and architecture
|
||||
2. **SETUP.md** - Step-by-step Authentik configuration
|
||||
3. **CONFIGURATION.md** - Environment variables reference
|
||||
4. **TROUBLESHOOTING.md** - Common issues and solutions
|
||||
5. **README.md** - Updated with authentication section
|
||||
|
||||
## Timeline Summary
|
||||
|
||||
| Phase | Duration | Key Deliverables |
|
||||
|-------|----------|------------------|
|
||||
| 1: Foundation | 2-3 days | Authentik setup, backend refactor, Redis |
|
||||
| 2: Auth Flow | 2-3 days | OIDC routes, token handling, callbacks |
|
||||
| 3: API Protection | 2-3 days | Middleware, protected endpoints, WebSocket auth |
|
||||
| 4: Frontend UI | 3-4 days | AuthContext, login page, user menu |
|
||||
| 5: Hardening | 2-3 days | Security, logging, testing, docs |
|
||||
| **Total** | **11-16 days** | Production-ready OIDC authentication |
|
||||
|
||||
## Approval & Next Steps
|
||||
|
||||
**This Plan:**
|
||||
- Provides complete roadmap for OIDC implementation
|
||||
- Addresses all security requirements
|
||||
- Maintains existing functionality
|
||||
- Enables phased rollout
|
||||
- Includes rollback strategy
|
||||
|
||||
**Next Steps:**
|
||||
1. Review and approve plan
|
||||
2. Set up Authentik provider/application
|
||||
3. Begin Phase 1 implementation
|
||||
4. Schedule checkpoints after each phase
|
||||
5. Plan production deployment
|
||||
|
||||
**Questions/Concerns:**
|
||||
- Authentik already configured or needs setup?
|
||||
- Redis instance available or needs provisioning?
|
||||
- Preferred timeline/priority adjustments?
|
||||
- Additional requirements or constraints?
|
||||
253
.prompts/001-oidc-auth-plan/completed/001-oidc-auth-plan.md
Normal file
253
.prompts/001-oidc-auth-plan/completed/001-oidc-auth-plan.md
Normal file
@@ -0,0 +1,253 @@
|
||||
<objective>
|
||||
Create an implementation roadmap for OIDC authentication with Authentik for Claude Web UI.
|
||||
|
||||
Purpose: Guide phased implementation of OIDC authentication with clear milestones
|
||||
Input: Current codebase structure, Authentik configuration requirements
|
||||
Output: oidc-auth-plan.md with 4-5 implementation phases
|
||||
</objective>
|
||||
|
||||
<context>
|
||||
Project: Claude Web UI - Web interface for Claude Code CLI
|
||||
URL: https://agents.sneakercloud.de
|
||||
Stack:
|
||||
- Backend: Node.js/Express (server.js) with WebSocket
|
||||
- Frontend: React/Vite with JSX components
|
||||
- Auth Provider: Authentik (https://auth.sneakercloud.de)
|
||||
|
||||
Current backend structure:
|
||||
- Single file: backend/server.js (~999 lines)
|
||||
- Express app with CORS, WebSocket server
|
||||
- No authentication currently implemented
|
||||
- REST endpoints: /api/hosts, /api/projects, /api/health, /api/browse, /api/upload, /api/history
|
||||
|
||||
Current frontend structure:
|
||||
- src/App.jsx - Main application
|
||||
- src/contexts/SessionContext.jsx - Session state management
|
||||
- src/hooks/useClaudeSession.js - WebSocket connection hook
|
||||
- src/components/ - UI components (ChatPanel, Sidebar, Header, etc.)
|
||||
|
||||
Authentication requirements:
|
||||
- OIDC Provider: Authentik
|
||||
- Auth Flow: Redirect Flow (Authorization Code)
|
||||
- Token Storage: httpOnly Cookies (XSS-safe)
|
||||
- Groups: agent-admin (full access), agent-users (standard access)
|
||||
- Session Duration: Configurable, with refresh token support
|
||||
</context>
|
||||
|
||||
<planning_requirements>
|
||||
Requirements to address:
|
||||
1. Authentik OIDC Application setup (Provider + Application)
|
||||
2. Backend OIDC middleware with session management
|
||||
3. Frontend AuthContext with login/logout flow
|
||||
4. Protected routes and API endpoints
|
||||
5. Group-based access control (admin vs users)
|
||||
6. httpOnly cookie-based token storage
|
||||
|
||||
Constraints:
|
||||
- Must work with existing WebSocket architecture
|
||||
- Minimal changes to existing components
|
||||
- Single-file backend (server.js) - consider refactoring into modules
|
||||
- Production-ready security (no localStorage tokens)
|
||||
|
||||
Success criteria:
|
||||
- Unauthenticated users see login page
|
||||
- Login redirects to Authentik, returns with session
|
||||
- WebSocket connections are authenticated
|
||||
- API endpoints check authentication
|
||||
- Admin users have additional capabilities
|
||||
- Sessions persist across browser refresh
|
||||
</planning_requirements>
|
||||
|
||||
<output_structure>
|
||||
Save to: `.prompts/001-oidc-auth-plan/oidc-auth-plan.md`
|
||||
|
||||
Structure the plan using this XML format:
|
||||
|
||||
```xml
|
||||
<plan>
|
||||
<summary>
|
||||
{One paragraph overview of the OIDC implementation approach}
|
||||
</summary>
|
||||
|
||||
<phases>
|
||||
<phase number="1" name="authentik-setup">
|
||||
<objective>Create OIDC Provider and Application in Authentik</objective>
|
||||
<tasks>
|
||||
<task priority="high">Create OAuth2/OIDC Provider in Authentik</task>
|
||||
<task priority="high">Configure redirect URIs for agents.sneakercloud.de</task>
|
||||
<task priority="high">Create Application and bind to Provider</task>
|
||||
<task priority="medium">Create groups: agent-admin, agent-users</task>
|
||||
<task priority="medium">Configure group claims in OIDC scope</task>
|
||||
</tasks>
|
||||
<deliverables>
|
||||
<deliverable>Authentik OIDC app configured</deliverable>
|
||||
<deliverable>Client ID and Secret generated</deliverable>
|
||||
<deliverable>Groups created and mapped</deliverable>
|
||||
</deliverables>
|
||||
<dependencies>Admin access to Authentik</dependencies>
|
||||
<execution_notes>
|
||||
Document the exact Authentik configuration steps.
|
||||
Save Client ID/Secret to .env (not committed).
|
||||
Test OIDC endpoints manually before proceeding.
|
||||
</execution_notes>
|
||||
</phase>
|
||||
|
||||
<phase number="2" name="backend-auth-module">
|
||||
<objective>Implement OIDC authentication in backend</objective>
|
||||
<tasks>
|
||||
<task priority="high">Install dependencies: openid-client, cookie-parser, express-session</task>
|
||||
<task priority="high">Create auth module with OIDC client configuration</task>
|
||||
<task priority="high">Implement /api/auth/login - redirect to Authentik</task>
|
||||
<task priority="high">Implement /api/auth/callback - handle OIDC response</task>
|
||||
<task priority="high">Implement /api/auth/logout - clear session</task>
|
||||
<task priority="medium">Implement /api/auth/me - return current user info</task>
|
||||
<task priority="medium">Create auth middleware for protected routes</task>
|
||||
<task priority="medium">Add session validation to WebSocket connections</task>
|
||||
</tasks>
|
||||
<deliverables>
|
||||
<deliverable>backend/auth.js module</deliverable>
|
||||
<deliverable>Session-based authentication with httpOnly cookies</deliverable>
|
||||
<deliverable>Protected API routes</deliverable>
|
||||
</deliverables>
|
||||
<dependencies>Phase 1 complete (Authentik configured)</dependencies>
|
||||
<execution_notes>
|
||||
Use openid-client for OIDC implementation.
|
||||
Store session in memory initially (can add Redis later).
|
||||
Validate ID tokens and extract user info.
|
||||
Pass user context to WebSocket sessions.
|
||||
</execution_notes>
|
||||
</phase>
|
||||
|
||||
<phase number="3" name="frontend-auth-context">
|
||||
<objective>Implement React authentication context and UI</objective>
|
||||
<tasks>
|
||||
<task priority="high">Create AuthContext with user state management</task>
|
||||
<task priority="high">Create useAuth hook for components</task>
|
||||
<task priority="high">Create LoginPage component</task>
|
||||
<task priority="high">Implement protected route wrapper</task>
|
||||
<task priority="medium">Add login/logout buttons to Header</task>
|
||||
<task priority="medium">Show user info in UI</task>
|
||||
<task priority="low">Add loading state during auth check</task>
|
||||
</tasks>
|
||||
<deliverables>
|
||||
<deliverable>src/contexts/AuthContext.jsx</deliverable>
|
||||
<deliverable>src/hooks/useAuth.js</deliverable>
|
||||
<deliverable>src/components/LoginPage.jsx</deliverable>
|
||||
<deliverable>src/components/ProtectedRoute.jsx</deliverable>
|
||||
</deliverables>
|
||||
<dependencies>Phase 2 complete (backend auth working)</dependencies>
|
||||
<execution_notes>
|
||||
Check /api/auth/me on app load to determine auth state.
|
||||
Redirect to login page if not authenticated.
|
||||
After login, redirect back to original URL.
|
||||
Handle auth errors gracefully.
|
||||
</execution_notes>
|
||||
</phase>
|
||||
|
||||
<phase number="4" name="group-permissions">
|
||||
<objective>Implement group-based access control</objective>
|
||||
<tasks>
|
||||
<task priority="high">Extract groups from OIDC claims in backend</task>
|
||||
<task priority="high">Add isAdmin flag to user context</task>
|
||||
<task priority="medium">Protect admin-only API routes</task>
|
||||
<task priority="medium">Show admin UI elements conditionally</task>
|
||||
<task priority="low">Add user management UI for admins (future)</task>
|
||||
</tasks>
|
||||
<deliverables>
|
||||
<deliverable>Group-based route protection</deliverable>
|
||||
<deliverable>Admin-specific UI elements</deliverable>
|
||||
</deliverables>
|
||||
<dependencies>Phase 3 complete</dependencies>
|
||||
<execution_notes>
|
||||
agent-admin: Full access, all hosts, all features
|
||||
agent-users: Limited hosts, standard features
|
||||
Check group membership on backend, pass to frontend.
|
||||
</execution_notes>
|
||||
</phase>
|
||||
|
||||
<phase number="5" name="testing-polish">
|
||||
<objective>Test, document, and polish the implementation</objective>
|
||||
<tasks>
|
||||
<task priority="high">Test full auth flow end-to-end</task>
|
||||
<task priority="high">Test WebSocket reconnection after session refresh</task>
|
||||
<task priority="medium">Handle edge cases (expired sessions, revoked tokens)</task>
|
||||
<task priority="medium">Update BookStack documentation</task>
|
||||
<task priority="low">Add session timeout warnings</task>
|
||||
</tasks>
|
||||
<deliverables>
|
||||
<deliverable>Working OIDC authentication</deliverable>
|
||||
<deliverable>Updated documentation</deliverable>
|
||||
</deliverables>
|
||||
<dependencies>Phase 4 complete</dependencies>
|
||||
</phase>
|
||||
</phases>
|
||||
|
||||
<metadata>
|
||||
<confidence level="high">
|
||||
OIDC with Authentik is well-documented pattern.
|
||||
httpOnly cookies with redirect flow is industry standard.
|
||||
openid-client is mature library for Node.js.
|
||||
</confidence>
|
||||
<dependencies>
|
||||
- Authentik admin access
|
||||
- DNS/SSL already configured (agents.sneakercloud.de)
|
||||
- Current app deployed and working
|
||||
</dependencies>
|
||||
<open_questions>
|
||||
- Session storage: In-memory vs Redis?
|
||||
- Token refresh strategy: Silent refresh vs re-login?
|
||||
- Should WebSocket auth use same session or separate token?
|
||||
</open_questions>
|
||||
<assumptions>
|
||||
- Authentik is accessible at auth.sneakercloud.de
|
||||
- User has admin access to create OIDC app
|
||||
- Current backend can be extended (single server.js file)
|
||||
- Frontend can add new components without major refactoring
|
||||
</assumptions>
|
||||
</metadata>
|
||||
</plan>
|
||||
```
|
||||
</output_structure>
|
||||
|
||||
<summary_requirements>
|
||||
Create `.prompts/001-oidc-auth-plan/SUMMARY.md`
|
||||
|
||||
Structure:
|
||||
```markdown
|
||||
# OIDC Auth Plan Summary
|
||||
|
||||
**{Substantive one-liner describing the plan}**
|
||||
|
||||
## Version
|
||||
v1
|
||||
|
||||
## Key Findings
|
||||
- {Phase 1 objective}
|
||||
- {Phase 2 objective}
|
||||
- {Phase 3 objective}
|
||||
- {Key architectural decision}
|
||||
|
||||
## Decisions Needed
|
||||
{Specific decisions requiring user input}
|
||||
|
||||
## Blockers
|
||||
{External impediments, or "None"}
|
||||
|
||||
## Next Step
|
||||
{Concrete forward action - likely "Execute Phase 1"}
|
||||
|
||||
---
|
||||
*Confidence: High*
|
||||
*Full output: oidc-auth-plan.md*
|
||||
```
|
||||
</summary_requirements>
|
||||
|
||||
<success_criteria>
|
||||
- Plan addresses all 5 requirements (Authentik, backend, frontend, routes, groups)
|
||||
- Phases are sequential and logically ordered
|
||||
- Each phase builds on previous deliverables
|
||||
- Tasks are specific and actionable
|
||||
- Metadata captures open questions and assumptions
|
||||
- SUMMARY.md created with phase overview
|
||||
- Ready for implementation prompts to consume
|
||||
</success_criteria>
|
||||
766
.prompts/001-oidc-auth-plan/oidc-auth-plan.md
Normal file
766
.prompts/001-oidc-auth-plan/oidc-auth-plan.md
Normal file
@@ -0,0 +1,766 @@
|
||||
# OIDC Authentication Implementation Plan for Claude Web UI
|
||||
|
||||
## Project Overview
|
||||
|
||||
**Project:** Claude Web UI - Web interface for Claude Code CLI
|
||||
**URL:** https://agents.sneakercloud.de
|
||||
**Auth Provider:** Authentik (https://auth.sneakercloud.de)
|
||||
**Implementation Goal:** Add production-ready OIDC authentication with group-based access control
|
||||
|
||||
## Current Architecture Analysis
|
||||
|
||||
### Backend (Node.js/Express)
|
||||
- **File:** `backend/server.js` (999 lines)
|
||||
- **Framework:** Express with WebSocket (ws library)
|
||||
- **Current State:** No authentication
|
||||
- **Endpoints:**
|
||||
- REST: `/api/hosts`, `/api/projects`, `/api/health`, `/api/browse`, `/api/upload/:sessionId`, `/api/history/:project`
|
||||
- WebSocket: Single endpoint on root path
|
||||
- **Sessions:** In-memory Map with UUIDs, no user association
|
||||
|
||||
### Frontend (React/Vite)
|
||||
- **Main:** `frontend/src/App.jsx` - Application shell
|
||||
- **State:** `frontend/src/contexts/SessionContext.jsx` - Session management via React Context
|
||||
- **WebSocket:** Direct connections stored in refs
|
||||
- **Storage:** localStorage for session persistence
|
||||
- **Current State:** No authentication UI or logic
|
||||
|
||||
### Key Constraints
|
||||
1. Single-file backend (consider modular refactoring)
|
||||
2. WebSocket architecture must be preserved
|
||||
3. No localStorage for tokens (XSS risk)
|
||||
4. Production security requirements
|
||||
5. Group-based access control needed
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Authentik Configuration & Backend Foundation
|
||||
|
||||
### Objectives
|
||||
- Configure Authentik OIDC Provider and Application
|
||||
- Refactor backend into modular structure
|
||||
- Add core authentication dependencies
|
||||
- Implement session storage mechanism
|
||||
|
||||
### Tasks
|
||||
|
||||
#### 1.1 Authentik Setup
|
||||
**Provider Configuration:**
|
||||
```yaml
|
||||
Name: Claude Web UI
|
||||
Client Type: Confidential
|
||||
Authorization Flow: Authorization Code
|
||||
Redirect URIs:
|
||||
- https://agents.sneakercloud.de/auth/callback
|
||||
- http://localhost:5173/auth/callback (dev)
|
||||
Scopes: openid, profile, email, groups
|
||||
```
|
||||
|
||||
**Application Configuration:**
|
||||
```yaml
|
||||
Name: Claude Web UI
|
||||
Provider: [linked to above]
|
||||
Launch URL: https://agents.sneakercloud.de
|
||||
```
|
||||
|
||||
**Group Mappings:**
|
||||
- Create groups: `agent-admin`, `agent-users`
|
||||
- Configure scope mappings to include groups in ID token
|
||||
- Test with user accounts
|
||||
|
||||
**Deliverables:**
|
||||
- Client ID and Client Secret
|
||||
- Discovery URL: `https://auth.sneakercloud.de/application/o/claude-web-ui/.well-known/openid-configuration`
|
||||
- Documented group structure
|
||||
|
||||
#### 1.2 Backend Refactoring
|
||||
**Current:** Single `backend/server.js` (999 lines)
|
||||
|
||||
**New Structure:**
|
||||
```
|
||||
backend/
|
||||
├── server.js # Main entry, app initialization
|
||||
├── config/
|
||||
│ └── auth.js # Auth config from env
|
||||
├── middleware/
|
||||
│ ├── auth.js # Authentication middleware
|
||||
│ └── session.js # Session management
|
||||
├── routes/
|
||||
│ ├── api.js # Existing API routes
|
||||
│ ├── auth.js # OIDC auth routes
|
||||
│ └── websocket.js # WebSocket handler
|
||||
└── utils/
|
||||
└── oidc.js # OIDC client wrapper
|
||||
```
|
||||
|
||||
**Migration Strategy:**
|
||||
1. Create new file structure
|
||||
2. Extract route handlers into modules
|
||||
3. Extract WebSocket logic
|
||||
4. Update server.js to import modules
|
||||
5. Test that existing functionality works
|
||||
|
||||
#### 1.3 Dependencies
|
||||
**Add to `backend/package.json`:**
|
||||
```json
|
||||
{
|
||||
"express-session": "^1.18.0",
|
||||
"connect-redis": "^7.1.0",
|
||||
"redis": "^4.6.0",
|
||||
"openid-client": "^5.6.0",
|
||||
"cookie-parser": "^1.4.6"
|
||||
}
|
||||
```
|
||||
|
||||
**Rationale:**
|
||||
- `express-session`: Session management
|
||||
- `connect-redis`: Session store (persistent, production-ready)
|
||||
- `redis`: Session backend
|
||||
- `openid-client`: Official OIDC client (certified)
|
||||
- `cookie-parser`: Cookie handling
|
||||
|
||||
#### 1.4 Session Storage
|
||||
**Redis Configuration:**
|
||||
- Use existing Redis instance or add to docker-compose
|
||||
- Configure session store with httpOnly cookies
|
||||
- Session TTL: 24 hours (configurable)
|
||||
- Secure flag in production
|
||||
|
||||
**Session Schema:**
|
||||
```javascript
|
||||
{
|
||||
sessionId: "uuid",
|
||||
userId: "oidc-sub",
|
||||
email: "user@example.com",
|
||||
name: "User Name",
|
||||
groups: ["agent-users"],
|
||||
accessToken: "encrypted",
|
||||
refreshToken: "encrypted",
|
||||
expiresAt: timestamp,
|
||||
createdAt: timestamp
|
||||
}
|
||||
```
|
||||
|
||||
### Verification Criteria
|
||||
- [ ] Authentik provider and application created
|
||||
- [ ] Test user can authenticate via Authentik UI
|
||||
- [ ] Backend refactored, all tests pass
|
||||
- [ ] Dependencies installed
|
||||
- [ ] Redis session store connected
|
||||
- [ ] Session CRUD operations working
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: OIDC Authentication Flow
|
||||
|
||||
### Objectives
|
||||
- Implement authorization code flow
|
||||
- Create auth routes (login, callback, logout)
|
||||
- Secure session cookies
|
||||
- Handle token refresh
|
||||
|
||||
### Tasks
|
||||
|
||||
#### 2.1 OIDC Client Setup
|
||||
**File:** `backend/utils/oidc.js`
|
||||
|
||||
**Functionality:**
|
||||
- Discover OIDC configuration from Authentik
|
||||
- Initialize Issuer and Client
|
||||
- Generate authorization URL
|
||||
- Handle token exchange
|
||||
- Validate ID tokens
|
||||
- Refresh access tokens
|
||||
|
||||
**Configuration (from env):**
|
||||
```bash
|
||||
OIDC_ISSUER=https://auth.sneakercloud.de/application/o/claude-web-ui/
|
||||
OIDC_CLIENT_ID=<from_authentik>
|
||||
OIDC_CLIENT_SECRET=<from_authentik>
|
||||
OIDC_REDIRECT_URI=https://agents.sneakercloud.de/auth/callback
|
||||
SESSION_SECRET=<generate_with_openssl>
|
||||
REDIS_URL=redis://localhost:6379
|
||||
```
|
||||
|
||||
#### 2.2 Authentication Routes
|
||||
**File:** `backend/routes/auth.js`
|
||||
|
||||
**Routes:**
|
||||
|
||||
**GET `/auth/login`**
|
||||
- Generate OIDC authorization URL with PKCE
|
||||
- Store state and nonce in session
|
||||
- Redirect to Authentik
|
||||
|
||||
**GET `/auth/callback`**
|
||||
- Validate state parameter
|
||||
- Exchange authorization code for tokens
|
||||
- Validate ID token signature and claims
|
||||
- Extract user info and groups
|
||||
- Create session with user data
|
||||
- Redirect to frontend (`/`)
|
||||
|
||||
**POST `/auth/logout`**
|
||||
- Destroy session
|
||||
- Optionally redirect to Authentik logout
|
||||
- Clear cookies
|
||||
|
||||
**GET `/auth/user`**
|
||||
- Return current user info from session
|
||||
- Return 401 if not authenticated
|
||||
|
||||
**POST `/auth/refresh`**
|
||||
- Use refresh token to get new access token
|
||||
- Update session
|
||||
- Return success/failure
|
||||
|
||||
#### 2.3 Session Cookie Configuration
|
||||
```javascript
|
||||
{
|
||||
name: 'claude.sid',
|
||||
secret: process.env.SESSION_SECRET,
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
cookie: {
|
||||
httpOnly: true, // No JavaScript access
|
||||
secure: true, // HTTPS only in production
|
||||
sameSite: 'lax', // CSRF protection
|
||||
maxAge: 24 * 60 * 60 * 1000 // 24 hours
|
||||
},
|
||||
store: redisStore // Redis session store
|
||||
}
|
||||
```
|
||||
|
||||
#### 2.4 Token Management
|
||||
- Store access token in Redis session (encrypted)
|
||||
- Store refresh token in Redis session (encrypted)
|
||||
- Implement token refresh 5 minutes before expiry
|
||||
- Handle refresh failures (force re-login)
|
||||
|
||||
### Verification Criteria
|
||||
- [ ] Login redirects to Authentik
|
||||
- [ ] Callback successfully exchanges code for tokens
|
||||
- [ ] ID token validated and parsed
|
||||
- [ ] Groups extracted from token
|
||||
- [ ] Session created in Redis
|
||||
- [ ] Cookie set with correct flags
|
||||
- [ ] `/auth/user` returns user info
|
||||
- [ ] Logout destroys session
|
||||
- [ ] Token refresh works
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Backend API Protection
|
||||
|
||||
### Objectives
|
||||
- Protect all API endpoints with authentication
|
||||
- Secure WebSocket connections
|
||||
- Implement group-based authorization
|
||||
- Handle unauthorized access gracefully
|
||||
|
||||
### Tasks
|
||||
|
||||
#### 3.1 Authentication Middleware
|
||||
**File:** `backend/middleware/auth.js`
|
||||
|
||||
**Middleware Functions:**
|
||||
|
||||
**`requireAuth`**
|
||||
- Check if session exists and is valid
|
||||
- Verify session in Redis
|
||||
- Attach `req.user` with user data
|
||||
- Return 401 if not authenticated
|
||||
|
||||
**`requireGroup(groups)`**
|
||||
- Check if `req.user.groups` includes required group
|
||||
- Return 403 if insufficient permissions
|
||||
- Usage: `requireGroup(['agent-admin'])`
|
||||
|
||||
**`optionalAuth`**
|
||||
- Attach user if authenticated
|
||||
- Continue if not authenticated
|
||||
- For endpoints that support both modes
|
||||
|
||||
#### 3.2 Protect REST Endpoints
|
||||
**Apply Middleware:**
|
||||
```javascript
|
||||
// All API routes require auth
|
||||
app.use('/api', requireAuth);
|
||||
|
||||
// Admin-only endpoints
|
||||
app.get('/api/admin/*', requireGroup(['agent-admin']));
|
||||
|
||||
// Health check remains public
|
||||
app.get('/api/health', (req, res) => ...);
|
||||
```
|
||||
|
||||
**Protected Endpoints:**
|
||||
- `/api/hosts` - Read: agent-users, Write: agent-admin
|
||||
- `/api/projects` - Read: agent-users
|
||||
- `/api/browse` - agent-users
|
||||
- `/api/upload/:sessionId` - agent-users
|
||||
- `/api/history/:project` - agent-users (own history only)
|
||||
|
||||
#### 3.3 WebSocket Authentication
|
||||
**Challenge:** WebSocket upgrade happens before HTTP middleware
|
||||
|
||||
**Solution: Cookie-based Auth**
|
||||
1. Parse cookies from upgrade request
|
||||
2. Load session from Redis
|
||||
3. Validate session
|
||||
4. Attach user to WebSocket connection
|
||||
5. Reject upgrade if not authenticated
|
||||
|
||||
**Implementation:**
|
||||
```javascript
|
||||
wss.on('connection', async (ws, req) => {
|
||||
// Parse cookies from req.headers.cookie
|
||||
// Load session from Redis
|
||||
// Validate user
|
||||
if (!user) {
|
||||
ws.close(1008, 'Unauthorized');
|
||||
return;
|
||||
}
|
||||
|
||||
// Attach user to connection
|
||||
ws.user = user;
|
||||
|
||||
// Continue with Claude session logic
|
||||
});
|
||||
```
|
||||
|
||||
#### 3.4 Session Association
|
||||
**Current:** Sessions stored by UUID, no user association
|
||||
|
||||
**New:**
|
||||
- Associate Claude sessions with authenticated user
|
||||
- Store user ID in session metadata
|
||||
- Allow users to only access their own sessions
|
||||
- Admin users can view all sessions (optional)
|
||||
|
||||
**Session Schema Update:**
|
||||
```javascript
|
||||
{
|
||||
sessionId: "uuid",
|
||||
userId: "oidc-sub", // NEW
|
||||
userEmail: "user@example.com", // NEW
|
||||
project: "/path/to/project",
|
||||
host: "neko",
|
||||
// ... existing fields
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.5 Error Handling
|
||||
- 401 Unauthorized: Not authenticated
|
||||
- 403 Forbidden: Insufficient permissions
|
||||
- Redirect to `/auth/login` for API calls
|
||||
- WebSocket: Close with error code
|
||||
- Frontend: Display login prompt
|
||||
|
||||
### Verification Criteria
|
||||
- [ ] Unauthenticated API calls return 401
|
||||
- [ ] WebSocket upgrades require valid session
|
||||
- [ ] Group-based access control works
|
||||
- [ ] Admin users have extended permissions
|
||||
- [ ] Sessions isolated by user
|
||||
- [ ] Error responses are clear
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Frontend Authentication UI
|
||||
|
||||
### Objectives
|
||||
- Create AuthContext for authentication state
|
||||
- Implement login/logout UI
|
||||
- Add protected routes
|
||||
- Handle authentication errors
|
||||
- Integrate with existing SessionContext
|
||||
|
||||
### Tasks
|
||||
|
||||
#### 4.1 AuthContext
|
||||
**File:** `frontend/src/contexts/AuthContext.jsx`
|
||||
|
||||
**State:**
|
||||
```javascript
|
||||
{
|
||||
user: null | {
|
||||
id: string,
|
||||
email: string,
|
||||
name: string,
|
||||
groups: string[]
|
||||
},
|
||||
isLoading: boolean,
|
||||
error: string | null
|
||||
}
|
||||
```
|
||||
|
||||
**Methods:**
|
||||
- `login()` - Redirect to `/auth/login`
|
||||
- `logout()` - Call `/auth/logout`, clear state
|
||||
- `refreshUser()` - Call `/auth/user` to get current user
|
||||
- `checkAuth()` - Verify authentication on mount
|
||||
|
||||
**Auto-refresh:**
|
||||
- Poll `/auth/user` every 5 minutes
|
||||
- Handle 401 by redirecting to login
|
||||
- Update user state on success
|
||||
|
||||
#### 4.2 Login Flow
|
||||
**Current:** App loads directly to ChatPanel
|
||||
|
||||
**New:**
|
||||
1. App mounts
|
||||
2. AuthContext checks `/auth/user`
|
||||
3. If 401: Show login page
|
||||
4. If 200: Show app
|
||||
5. User clicks "Login with Authentik"
|
||||
6. Redirect to `/auth/login`
|
||||
7. Authentik authentication
|
||||
8. Callback redirects to `/`
|
||||
9. AuthContext refreshes user
|
||||
10. App shows
|
||||
|
||||
#### 4.3 Login Page Component
|
||||
**File:** `frontend/src/components/LoginPage.jsx`
|
||||
|
||||
**UI:**
|
||||
- Centered card
|
||||
- Claude Web UI branding
|
||||
- "Login with Authentik" button
|
||||
- Loading state during redirect
|
||||
- Error messages
|
||||
|
||||
**Design:**
|
||||
- Match existing dark theme
|
||||
- Simple, professional
|
||||
- No local credentials (OIDC only)
|
||||
|
||||
#### 4.4 Protected App Wrapper
|
||||
**File:** `frontend/src/App.jsx` (update)
|
||||
|
||||
**Logic:**
|
||||
```javascript
|
||||
function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<AuthenticatedApp />
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthenticatedApp() {
|
||||
const { user, isLoading } = useAuth();
|
||||
|
||||
if (isLoading) return <LoadingSpinner />;
|
||||
if (!user) return <LoginPage />;
|
||||
|
||||
return (
|
||||
<SessionProvider>
|
||||
<AppContent />
|
||||
</SessionProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### 4.5 User Menu
|
||||
**Location:** Header component
|
||||
|
||||
**Elements:**
|
||||
- User name/email display
|
||||
- Group badges (admin/user)
|
||||
- Logout button
|
||||
- Account settings link (optional)
|
||||
|
||||
**Functionality:**
|
||||
- Dropdown menu
|
||||
- Logout calls `logout()` method
|
||||
- Shows current user info
|
||||
|
||||
#### 4.6 Session Integration
|
||||
**Update:** `frontend/src/contexts/SessionContext.jsx`
|
||||
|
||||
**Changes:**
|
||||
- Include user in WebSocket connection metadata
|
||||
- Filter sessions by current user
|
||||
- Add user info to session creation
|
||||
- Handle 401 errors by triggering AuthContext logout
|
||||
|
||||
**WebSocket Headers:**
|
||||
- Session cookie automatically included
|
||||
- Backend validates on upgrade
|
||||
|
||||
### Verification Criteria
|
||||
- [ ] Unauthenticated users see login page
|
||||
- [ ] Login redirects to Authentik
|
||||
- [ ] Successful login shows app
|
||||
- [ ] User info displayed in UI
|
||||
- [ ] Logout clears session and returns to login
|
||||
- [ ] 401 errors trigger re-authentication
|
||||
- [ ] WebSocket connects with auth
|
||||
- [ ] Sessions filtered by user
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Production Hardening & Polish
|
||||
|
||||
### Objectives
|
||||
- Implement security best practices
|
||||
- Add monitoring and logging
|
||||
- Handle edge cases
|
||||
- Document configuration
|
||||
- Test thoroughly
|
||||
|
||||
### Tasks
|
||||
|
||||
#### 5.1 Security Enhancements
|
||||
|
||||
**CSRF Protection:**
|
||||
- Use `sameSite: 'lax'` on cookies
|
||||
- Add CSRF tokens for state-changing operations
|
||||
- Validate origin headers on WebSocket upgrade
|
||||
|
||||
**Rate Limiting:**
|
||||
- Add `express-rate-limit` to auth endpoints
|
||||
- Limit login attempts per IP
|
||||
- Limit token refresh attempts
|
||||
|
||||
**Token Security:**
|
||||
- Encrypt tokens at rest in Redis (using `crypto`)
|
||||
- Validate token expiry before use
|
||||
- Clear tokens on logout
|
||||
|
||||
**Content Security Policy:**
|
||||
- Add CSP headers
|
||||
- Allow only Authentik domain for redirects
|
||||
- Restrict inline scripts
|
||||
|
||||
#### 5.2 Logging & Monitoring
|
||||
|
||||
**Authentication Events:**
|
||||
- Log successful logins (user ID, IP, timestamp)
|
||||
- Log failed auth attempts
|
||||
- Log token refresh failures
|
||||
- Log logout events
|
||||
|
||||
**Metrics:**
|
||||
- Active sessions count
|
||||
- Authentication failures rate
|
||||
- Token refresh rate
|
||||
- WebSocket auth failures
|
||||
|
||||
**Integration:**
|
||||
- Use existing Gotify webhook for alerts
|
||||
- Log to stdout (Docker logs)
|
||||
- Consider structured logging (winston)
|
||||
|
||||
#### 5.3 Error Handling
|
||||
|
||||
**Token Expiry:**
|
||||
- Detect access token expiry
|
||||
- Auto-refresh using refresh token
|
||||
- Prompt re-login if refresh fails
|
||||
- Show clear error messages
|
||||
|
||||
**Session Expiry:**
|
||||
- Handle expired sessions gracefully
|
||||
- Show "Session expired, please login again"
|
||||
- Preserve unsaved work if possible
|
||||
|
||||
**Network Errors:**
|
||||
- Retry logic for auth endpoint calls
|
||||
- Offline detection
|
||||
- Clear error messages
|
||||
|
||||
**Authentik Downtime:**
|
||||
- Cache user info for graceful degradation
|
||||
- Allow continued use of valid sessions
|
||||
- Queue auth checks
|
||||
|
||||
#### 5.4 Environment Configuration
|
||||
|
||||
**Required Env Vars:**
|
||||
```bash
|
||||
# OIDC
|
||||
OIDC_ISSUER=https://auth.sneakercloud.de/application/o/claude-web-ui/
|
||||
OIDC_CLIENT_ID=<client_id>
|
||||
OIDC_CLIENT_SECRET=<client_secret>
|
||||
OIDC_REDIRECT_URI=https://agents.sneakercloud.de/auth/callback
|
||||
|
||||
# Session
|
||||
SESSION_SECRET=<generate_with_openssl_rand_hex_32>
|
||||
SESSION_DOMAIN=.sneakercloud.de
|
||||
SESSION_SECURE=true
|
||||
SESSION_MAX_AGE=86400000 # 24 hours
|
||||
|
||||
# Redis
|
||||
REDIS_URL=redis://localhost:6379
|
||||
REDIS_PASSWORD=<optional>
|
||||
|
||||
# App
|
||||
NODE_ENV=production
|
||||
FRONTEND_URL=https://agents.sneakercloud.de
|
||||
```
|
||||
|
||||
**docker-compose.yml Updates:**
|
||||
```yaml
|
||||
services:
|
||||
backend:
|
||||
environment:
|
||||
- OIDC_ISSUER=${OIDC_ISSUER}
|
||||
- OIDC_CLIENT_ID=${OIDC_CLIENT_ID}
|
||||
- OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET}
|
||||
- SESSION_SECRET=${SESSION_SECRET}
|
||||
- REDIS_URL=redis://redis:6379
|
||||
depends_on:
|
||||
- redis
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
networks:
|
||||
- claude-internal
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
|
||||
volumes:
|
||||
redis-data:
|
||||
```
|
||||
|
||||
#### 5.5 Documentation
|
||||
|
||||
**Create Files:**
|
||||
- `docs/AUTHENTICATION.md` - Overview of auth system
|
||||
- `docs/SETUP.md` - Step-by-step Authentik setup
|
||||
- `docs/CONFIGURATION.md` - Environment variables
|
||||
- `docs/TROUBLESHOOTING.md` - Common issues
|
||||
|
||||
**Update README.md:**
|
||||
- Add authentication section
|
||||
- Document required groups
|
||||
- Link to setup docs
|
||||
|
||||
#### 5.6 Testing
|
||||
|
||||
**Manual Tests:**
|
||||
- [ ] Fresh login flow
|
||||
- [ ] Logout and re-login
|
||||
- [ ] Session persistence across browser refresh
|
||||
- [ ] Token refresh
|
||||
- [ ] Expired session handling
|
||||
- [ ] WebSocket auth
|
||||
- [ ] Group-based access control
|
||||
- [ ] Multiple users simultaneously
|
||||
- [ ] Admin vs regular user permissions
|
||||
|
||||
**Edge Cases:**
|
||||
- [ ] Authentik downtime during login
|
||||
- [ ] Redis downtime (session loss)
|
||||
- [ ] Concurrent logins from same user
|
||||
- [ ] Token refresh during active WebSocket
|
||||
- [ ] Cookie disabled in browser
|
||||
- [ ] CORS issues
|
||||
|
||||
**Security Tests:**
|
||||
- [ ] Cannot access API without auth
|
||||
- [ ] Cannot access other users' sessions
|
||||
- [ ] XSS attacks blocked (httpOnly cookies)
|
||||
- [ ] CSRF protection works
|
||||
- [ ] Token replay attacks prevented
|
||||
|
||||
### Verification Criteria
|
||||
- [ ] All security enhancements implemented
|
||||
- [ ] Logging captures auth events
|
||||
- [ ] Error handling covers all cases
|
||||
- [ ] Documentation complete
|
||||
- [ ] All tests pass
|
||||
- [ ] Production deployment successful
|
||||
|
||||
---
|
||||
|
||||
## Implementation Timeline
|
||||
|
||||
**Phase 1:** Authentik Configuration & Backend Foundation
|
||||
**Duration:** 2-3 days
|
||||
**Blockers:** None
|
||||
|
||||
**Phase 2:** OIDC Authentication Flow
|
||||
**Duration:** 2-3 days
|
||||
**Blockers:** Phase 1 complete
|
||||
|
||||
**Phase 3:** Backend API Protection
|
||||
**Duration:** 2-3 days
|
||||
**Blockers:** Phase 2 complete
|
||||
|
||||
**Phase 4:** Frontend Authentication UI
|
||||
**Duration:** 3-4 days
|
||||
**Blockers:** Phase 3 complete
|
||||
|
||||
**Phase 5:** Production Hardening & Polish
|
||||
**Duration:** 2-3 days
|
||||
**Blockers:** Phase 4 complete
|
||||
|
||||
**Total Estimated Time:** 11-16 days
|
||||
|
||||
---
|
||||
|
||||
## Success Metrics
|
||||
|
||||
1. **Security:**
|
||||
- Zero XSS vulnerabilities (httpOnly cookies)
|
||||
- Zero unauthorized access incidents
|
||||
- All tokens encrypted at rest
|
||||
|
||||
2. **User Experience:**
|
||||
- Seamless login flow (<5 seconds)
|
||||
- Clear error messages
|
||||
- No unnecessary re-authentication
|
||||
|
||||
3. **Reliability:**
|
||||
- 99.9% authentication availability
|
||||
- Session persistence across restarts
|
||||
- Graceful degradation on errors
|
||||
|
||||
4. **Performance:**
|
||||
- <100ms auth middleware overhead
|
||||
- No impact on WebSocket latency
|
||||
- Redis session lookups <10ms
|
||||
|
||||
---
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
**If Critical Issues Arise:**
|
||||
|
||||
1. **Immediate:** Disable authentication middleware
|
||||
2. **Revert:** Return to unauthenticated mode
|
||||
3. **Investigate:** Review logs and error reports
|
||||
4. **Fix:** Address issues in isolated environment
|
||||
5. **Redeploy:** With fixes applied
|
||||
|
||||
**Feature Flags:**
|
||||
- `AUTH_ENABLED=false` to disable auth
|
||||
- Keep unauthenticated code path intact during initial rollout
|
||||
|
||||
---
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
**Post-MVP:**
|
||||
- Multi-factor authentication (MFA) via Authentik
|
||||
- API keys for programmatic access
|
||||
- OAuth2 scopes for fine-grained permissions
|
||||
- Audit log for all user actions
|
||||
- Session management UI (view/revoke sessions)
|
||||
- SSO with other services via Authentik
|
||||
- User preferences storage
|
||||
- Role-based access control (RBAC) expansion
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- **Authentik Docs:** https://docs.goauthentik.io/
|
||||
- **OIDC Spec:** https://openid.net/connect/
|
||||
- **openid-client:** https://github.com/panva/node-openid-client
|
||||
- **express-session:** https://github.com/expressjs/session
|
||||
- **OWASP Auth Cheatsheet:** https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
|
||||
62
backend/config/auth.js
Normal file
62
backend/config/auth.js
Normal file
@@ -0,0 +1,62 @@
|
||||
// Auth configuration from environment variables
|
||||
|
||||
export const authConfig = {
|
||||
// OIDC Configuration
|
||||
oidc: {
|
||||
issuer: process.env.OIDC_ISSUER,
|
||||
clientId: process.env.OIDC_CLIENT_ID,
|
||||
clientSecret: process.env.OIDC_CLIENT_SECRET,
|
||||
redirectUri: process.env.OIDC_REDIRECT_URI,
|
||||
scopes: ['openid', 'profile', 'email', 'groups', 'offline_access'],
|
||||
},
|
||||
|
||||
// Session Configuration
|
||||
session: {
|
||||
secret: process.env.SESSION_SECRET,
|
||||
name: 'claude.sid',
|
||||
domain: process.env.SESSION_DOMAIN || undefined,
|
||||
secure: process.env.SESSION_SECURE === 'true',
|
||||
maxAge: parseInt(process.env.SESSION_MAX_AGE) || 86400000, // 24 hours
|
||||
},
|
||||
|
||||
// Redis Configuration
|
||||
redis: {
|
||||
url: process.env.REDIS_URL || 'redis://localhost:6379',
|
||||
},
|
||||
|
||||
// App Configuration
|
||||
app: {
|
||||
frontendUrl: process.env.FRONTEND_URL || 'http://localhost:5173',
|
||||
authEnabled: process.env.AUTH_ENABLED !== 'false',
|
||||
},
|
||||
|
||||
// Group Configuration (must match Authentik group names)
|
||||
groups: {
|
||||
admin: 'agent-admins',
|
||||
users: 'agent-users',
|
||||
allowedGroups: ['agent-admins', 'agent-users'],
|
||||
},
|
||||
};
|
||||
|
||||
// Validate required config
|
||||
export function validateConfig() {
|
||||
const { oidc, session, app } = authConfig;
|
||||
const errors = [];
|
||||
|
||||
if (app.authEnabled) {
|
||||
if (!oidc.issuer) errors.push('OIDC_ISSUER is required');
|
||||
if (!oidc.clientId) errors.push('OIDC_CLIENT_ID is required');
|
||||
if (!oidc.clientSecret) errors.push('OIDC_CLIENT_SECRET is required');
|
||||
if (!oidc.redirectUri) errors.push('OIDC_REDIRECT_URI is required');
|
||||
if (!session.secret) errors.push('SESSION_SECRET is required');
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error('Auth configuration errors:', errors);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export default authConfig;
|
||||
184
backend/middleware/auth.js
Normal file
184
backend/middleware/auth.js
Normal file
@@ -0,0 +1,184 @@
|
||||
// Authentication middleware
|
||||
|
||||
import { authConfig } from '../config/auth.js';
|
||||
|
||||
/**
|
||||
* Require authentication for a route
|
||||
* Sets req.user if authenticated, returns 401 if not
|
||||
*/
|
||||
export function requireAuth(req, res, next) {
|
||||
console.log(`[requireAuth] ${req.method} ${req.path} - session ID: ${req.session?.id || 'NONE'}, has user: ${!!req.session?.user}`);
|
||||
|
||||
// Skip auth if disabled
|
||||
if (!authConfig.app.authEnabled) {
|
||||
req.user = { id: 'anonymous', email: 'anonymous@local', groups: ['agent-admin'] };
|
||||
return next();
|
||||
}
|
||||
|
||||
// Check session
|
||||
if (!req.session || !req.session.user) {
|
||||
console.log(`[requireAuth] Unauthorized - no session or user`);
|
||||
return res.status(401).json({
|
||||
error: 'Unauthorized',
|
||||
message: 'Authentication required',
|
||||
loginUrl: '/auth/login',
|
||||
});
|
||||
}
|
||||
|
||||
// Attach user to request
|
||||
req.user = req.session.user;
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Require specific group membership
|
||||
* Must be used after requireAuth
|
||||
*/
|
||||
export function requireGroup(allowedGroups) {
|
||||
return (req, res, next) => {
|
||||
// Skip if auth disabled
|
||||
if (!authConfig.app.authEnabled) {
|
||||
return next();
|
||||
}
|
||||
|
||||
if (!req.user) {
|
||||
return res.status(401).json({
|
||||
error: 'Unauthorized',
|
||||
message: 'Authentication required',
|
||||
});
|
||||
}
|
||||
|
||||
const userGroups = req.user.groups || [];
|
||||
const hasGroup = allowedGroups.some(g => userGroups.includes(g));
|
||||
|
||||
if (!hasGroup) {
|
||||
return res.status(403).json({
|
||||
error: 'Forbidden',
|
||||
message: `Required group: ${allowedGroups.join(' or ')}`,
|
||||
userGroups,
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional authentication - attach user if present but don't require it
|
||||
*/
|
||||
export function optionalAuth(req, res, next) {
|
||||
if (!authConfig.app.authEnabled) {
|
||||
req.user = null;
|
||||
return next();
|
||||
}
|
||||
|
||||
if (req.session && req.session.user) {
|
||||
req.user = req.session.user;
|
||||
} else {
|
||||
req.user = null;
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is admin
|
||||
*/
|
||||
export function isAdmin(user) {
|
||||
if (!user || !user.groups) return false;
|
||||
return user.groups.includes(authConfig.groups.admin);
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate WebSocket upgrade request
|
||||
* Returns user object or null
|
||||
*/
|
||||
export async function authenticateWebSocket(req, sessionStore) {
|
||||
// Skip auth if disabled
|
||||
if (!authConfig.app.authEnabled) {
|
||||
return { id: 'anonymous', email: 'anonymous@local', groups: ['agent-admin'] };
|
||||
}
|
||||
|
||||
// Parse session ID from cookies
|
||||
const cookies = parseCookies(req.headers.cookie || '');
|
||||
const sessionCookie = cookies[authConfig.session.name];
|
||||
|
||||
if (!sessionCookie) {
|
||||
console.log('[WS Auth] No session cookie found');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Session cookie format: s:sessionId.signature
|
||||
// We need to extract the session ID
|
||||
const sessionId = decodeSessionCookie(sessionCookie, authConfig.session.secret);
|
||||
|
||||
if (!sessionId) {
|
||||
console.log('[WS Auth] Invalid session cookie');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Load session from store
|
||||
return new Promise((resolve) => {
|
||||
sessionStore.get(sessionId, (err, session) => {
|
||||
if (err || !session || !session.user) {
|
||||
console.log('[WS Auth] Session not found or invalid');
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[WS Auth] Authenticated user: ${session.user.email}`);
|
||||
resolve(session.user);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse cookies from header string
|
||||
*/
|
||||
function parseCookies(cookieHeader) {
|
||||
const cookies = {};
|
||||
if (!cookieHeader) return cookies;
|
||||
|
||||
cookieHeader.split(';').forEach(cookie => {
|
||||
const [name, ...rest] = cookie.trim().split('=');
|
||||
cookies[name] = rest.join('=');
|
||||
});
|
||||
|
||||
return cookies;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode express-session cookie
|
||||
* Cookie format: s:sessionId.signature (URL encoded)
|
||||
*/
|
||||
function decodeSessionCookie(cookie, secret) {
|
||||
try {
|
||||
// URL decode
|
||||
const decoded = decodeURIComponent(cookie);
|
||||
|
||||
// Check for signed cookie prefix
|
||||
if (!decoded.startsWith('s:')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract session ID (before the signature)
|
||||
const withoutPrefix = decoded.substring(2);
|
||||
const dotIndex = withoutPrefix.lastIndexOf('.');
|
||||
|
||||
if (dotIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return withoutPrefix.substring(0, dotIndex);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
requireAuth,
|
||||
requireGroup,
|
||||
optionalAuth,
|
||||
isAdmin,
|
||||
authenticateWebSocket,
|
||||
};
|
||||
@@ -13,6 +13,11 @@
|
||||
"ws": "^8.14.2",
|
||||
"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
262
backend/routes/auth.js
Normal file
@@ -0,0 +1,262 @@
|
||||
// Authentication routes
|
||||
|
||||
import { Router } from 'express';
|
||||
import { authConfig } from '../config/auth.js';
|
||||
import oidc from '../utils/oidc.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* GET /auth/login
|
||||
* Redirect to OIDC provider for authentication
|
||||
*/
|
||||
router.get('/login', async (req, res) => {
|
||||
if (!authConfig.app.authEnabled) {
|
||||
return res.redirect(authConfig.app.frontendUrl);
|
||||
}
|
||||
|
||||
try {
|
||||
// Generate PKCE and state values
|
||||
const state = oidc.generateState();
|
||||
const nonce = oidc.generateNonce();
|
||||
const codeVerifier = oidc.generateCodeVerifier();
|
||||
|
||||
// Store in session for validation
|
||||
req.session.authState = state;
|
||||
req.session.authNonce = nonce;
|
||||
req.session.codeVerifier = codeVerifier;
|
||||
|
||||
// Store return URL if provided
|
||||
const returnTo = req.query.returnTo || '/';
|
||||
req.session.returnTo = returnTo;
|
||||
|
||||
// Get authorization URL
|
||||
const authUrl = oidc.getAuthorizationUrl(state, nonce, codeVerifier);
|
||||
|
||||
// Explicitly save session before redirect (required for async stores like Redis)
|
||||
await new Promise((resolve, reject) => {
|
||||
req.session.save((err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
|
||||
console.log(`[Auth] Login initiated, session ID: ${req.session.id}, state: ${state.substring(0, 10)}...`);
|
||||
res.redirect(authUrl);
|
||||
} catch (error) {
|
||||
console.error('[Auth] Login error:', error);
|
||||
res.status(500).json({ error: 'Failed to initiate login' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /auth/callback
|
||||
* Handle OIDC callback after authentication
|
||||
*/
|
||||
router.get('/callback', async (req, res) => {
|
||||
if (!authConfig.app.authEnabled) {
|
||||
return res.redirect(authConfig.app.frontendUrl);
|
||||
}
|
||||
|
||||
try {
|
||||
const { code, state, error, error_description } = req.query;
|
||||
|
||||
// Check for error from provider
|
||||
if (error) {
|
||||
console.error(`[Auth] Provider error: ${error} - ${error_description}`);
|
||||
return res.redirect(`${authConfig.app.frontendUrl}/login?error=${encodeURIComponent(error_description || error)}`);
|
||||
}
|
||||
|
||||
// Validate state
|
||||
console.log(`[Auth] Callback received, session ID: ${req.session?.id}, stored state: ${req.session?.authState?.substring(0, 10) || 'NONE'}..., received state: ${state?.substring(0, 10)}...`);
|
||||
if (state !== req.session.authState) {
|
||||
console.error(`[Auth] State mismatch - expected: ${req.session.authState}, got: ${state}`);
|
||||
return res.redirect(`${authConfig.app.frontendUrl}/login?error=invalid_state`);
|
||||
}
|
||||
|
||||
// Exchange code for tokens (nonce validation happens inside openid-client)
|
||||
const tokenSet = await oidc.exchangeCode(code, req.session.codeVerifier, req.session.authNonce);
|
||||
|
||||
// Get claims from validated token
|
||||
const claims = oidc.getIdTokenClaims(tokenSet);
|
||||
|
||||
// Extract user info
|
||||
const userInfo = await oidc.getUserInfo(tokenSet.access_token);
|
||||
|
||||
// Debug: Log claims and userInfo to see available fields
|
||||
console.log('[Auth] ID Token claims:', JSON.stringify(claims, null, 2));
|
||||
console.log('[Auth] UserInfo:', JSON.stringify(userInfo, null, 2));
|
||||
|
||||
// Extract groups from claims or userInfo
|
||||
let groups = claims.groups || userInfo.groups || [];
|
||||
|
||||
// Filter to allowed groups
|
||||
groups = groups.filter(g => authConfig.groups.allowedGroups.includes(g));
|
||||
|
||||
// Check if user has any allowed group
|
||||
if (groups.length === 0) {
|
||||
console.error(`[Auth] User ${userInfo.email} has no allowed groups`);
|
||||
return res.redirect(`${authConfig.app.frontendUrl}/login?error=no_access`);
|
||||
}
|
||||
|
||||
// Create user session
|
||||
const user = {
|
||||
id: claims.sub,
|
||||
email: userInfo.email || claims.email,
|
||||
name: userInfo.name || claims.name || userInfo.preferred_username,
|
||||
avatar: userInfo.picture || claims.picture || null,
|
||||
groups,
|
||||
isAdmin: groups.includes(authConfig.groups.admin),
|
||||
};
|
||||
|
||||
// Store user and tokens in session
|
||||
req.session.user = user;
|
||||
req.session.tokens = {
|
||||
accessToken: tokenSet.access_token,
|
||||
refreshToken: tokenSet.refresh_token,
|
||||
idToken: tokenSet.id_token,
|
||||
expiresAt: tokenSet.expires_at ? tokenSet.expires_at * 1000 : Date.now() + 3600000,
|
||||
};
|
||||
|
||||
// Debug: Log token info
|
||||
console.log(`[Auth] Token info - expires_at: ${tokenSet.expires_at}, calculated expiresAt: ${req.session.tokens.expiresAt}, has refresh_token: ${!!tokenSet.refresh_token}`);
|
||||
|
||||
// Clear auth state
|
||||
delete req.session.authState;
|
||||
delete req.session.authNonce;
|
||||
delete req.session.codeVerifier;
|
||||
|
||||
// Get return URL
|
||||
const returnTo = req.session.returnTo || '/';
|
||||
delete req.session.returnTo;
|
||||
|
||||
console.log(`[Auth] User ${user.email} logged in successfully (groups: ${groups.join(', ')})`);
|
||||
|
||||
// Redirect to frontend
|
||||
res.redirect(`${authConfig.app.frontendUrl}${returnTo}`);
|
||||
} catch (error) {
|
||||
console.error('[Auth] Callback error:', error);
|
||||
res.redirect(`${authConfig.app.frontendUrl}/login?error=auth_failed`);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /auth/logout
|
||||
* Clear session and optionally redirect to OIDC logout
|
||||
*/
|
||||
router.post('/logout', (req, res) => {
|
||||
if (!authConfig.app.authEnabled) {
|
||||
return res.json({ success: true });
|
||||
}
|
||||
|
||||
const idToken = req.session.tokens?.idToken;
|
||||
const userEmail = req.session.user?.email;
|
||||
|
||||
// Destroy session
|
||||
req.session.destroy((err) => {
|
||||
if (err) {
|
||||
console.error('[Auth] Logout error:', err);
|
||||
return res.status(500).json({ error: 'Logout failed' });
|
||||
}
|
||||
|
||||
// Clear cookie
|
||||
res.clearCookie(authConfig.session.name);
|
||||
|
||||
console.log(`[Auth] User ${userEmail || 'unknown'} logged out`);
|
||||
|
||||
// Return logout URL if available
|
||||
if (idToken) {
|
||||
try {
|
||||
const logoutUrl = oidc.getEndSessionUrl(idToken, authConfig.app.frontendUrl);
|
||||
return res.json({ success: true, logoutUrl });
|
||||
} catch (e) {
|
||||
// Issuer might not support end_session
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /auth/user
|
||||
* Get current authenticated user
|
||||
*/
|
||||
router.get('/user', (req, res) => {
|
||||
console.log(`[Auth] /user called, session ID: ${req.session?.id}, has user: ${!!req.session?.user}`);
|
||||
|
||||
if (!authConfig.app.authEnabled) {
|
||||
return res.json({
|
||||
user: { id: 'anonymous', email: 'anonymous@local', name: 'Anonymous', groups: ['agent-admin'], isAdmin: true },
|
||||
authEnabled: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (!req.session || !req.session.user) {
|
||||
console.log(`[Auth] /user - not authenticated, session: ${JSON.stringify(req.session || {})}`);
|
||||
return res.status(401).json({ error: 'Not authenticated' });
|
||||
}
|
||||
|
||||
console.log(`[Auth] /user - returning user: ${req.session.user.email}`);
|
||||
res.json({
|
||||
user: req.session.user,
|
||||
authEnabled: true,
|
||||
expiresAt: req.session.tokens?.expiresAt,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /auth/refresh
|
||||
* Refresh access token
|
||||
*/
|
||||
router.post('/refresh', async (req, res) => {
|
||||
console.log(`[Auth] /refresh called, session ID: ${req.session?.id}, has refreshToken: ${!!req.session?.tokens?.refreshToken}`);
|
||||
|
||||
if (!authConfig.app.authEnabled) {
|
||||
return res.json({ success: true });
|
||||
}
|
||||
|
||||
if (!req.session || !req.session.tokens?.refreshToken) {
|
||||
console.log('[Auth] /refresh - no refresh token in session');
|
||||
return res.status(401).json({ error: 'No refresh token' });
|
||||
}
|
||||
|
||||
try {
|
||||
const newTokenSet = await oidc.refreshTokens(req.session.tokens.refreshToken);
|
||||
|
||||
req.session.tokens = {
|
||||
accessToken: newTokenSet.access_token,
|
||||
refreshToken: newTokenSet.refresh_token || req.session.tokens.refreshToken,
|
||||
idToken: newTokenSet.id_token || req.session.tokens.idToken,
|
||||
expiresAt: newTokenSet.expires_at ? newTokenSet.expires_at * 1000 : Date.now() + 3600000,
|
||||
};
|
||||
|
||||
console.log(`[Auth] Token refreshed for user ${req.session.user?.email}`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
expiresAt: req.session.tokens.expiresAt,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Auth] Token refresh failed:', error.message);
|
||||
res.status(401).json({ error: 'Token refresh failed', message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /auth/status
|
||||
* Check auth status and config
|
||||
*/
|
||||
router.get('/status', (req, res) => {
|
||||
res.json({
|
||||
authEnabled: authConfig.app.authEnabled,
|
||||
isAuthenticated: !!(req.session && req.session.user),
|
||||
user: req.session?.user ? {
|
||||
email: req.session.user.email,
|
||||
name: req.session.user.name,
|
||||
isAdmin: req.session.user.isAdmin,
|
||||
} : null,
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -7,10 +7,32 @@ import cors from 'cors';
|
||||
import { existsSync, readFileSync, readdirSync, statSync, mkdirSync, writeFileSync } from 'fs';
|
||||
import { 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) => {
|
||||
});
|
||||
});
|
||||
|
||||
// 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
146
backend/utils/oidc.js
Normal file
@@ -0,0 +1,146 @@
|
||||
// OIDC Client Wrapper using openid-client
|
||||
|
||||
import { Issuer, generators } from 'openid-client';
|
||||
import { authConfig } from '../config/auth.js';
|
||||
|
||||
let oidcClient = null;
|
||||
let issuer = null;
|
||||
|
||||
/**
|
||||
* Initialize the OIDC client by discovering the issuer
|
||||
*/
|
||||
export async function initializeOIDC() {
|
||||
if (!authConfig.app.authEnabled) {
|
||||
console.log('[OIDC] Authentication disabled, skipping initialization');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`[OIDC] Discovering issuer: ${authConfig.oidc.issuer}`);
|
||||
issuer = await Issuer.discover(authConfig.oidc.issuer);
|
||||
console.log(`[OIDC] Discovered issuer: ${issuer.issuer}`);
|
||||
|
||||
oidcClient = new issuer.Client({
|
||||
client_id: authConfig.oidc.clientId,
|
||||
client_secret: authConfig.oidc.clientSecret,
|
||||
redirect_uris: [authConfig.oidc.redirectUri],
|
||||
response_types: ['code'],
|
||||
});
|
||||
|
||||
console.log('[OIDC] Client initialized successfully');
|
||||
return oidcClient;
|
||||
} catch (error) {
|
||||
console.error('[OIDC] Failed to initialize client:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the initialized OIDC client
|
||||
*/
|
||||
export function getClient() {
|
||||
if (!oidcClient) {
|
||||
throw new Error('OIDC client not initialized. Call initializeOIDC() first.');
|
||||
}
|
||||
return oidcClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate authorization URL for login
|
||||
*/
|
||||
export function getAuthorizationUrl(state, nonce, codeVerifier) {
|
||||
const client = getClient();
|
||||
const codeChallenge = generators.codeChallenge(codeVerifier);
|
||||
|
||||
return client.authorizationUrl({
|
||||
scope: authConfig.oidc.scopes.join(' '),
|
||||
state,
|
||||
nonce,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: 'S256',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange authorization code for tokens
|
||||
*/
|
||||
export async function exchangeCode(code, codeVerifier, nonce) {
|
||||
const client = getClient();
|
||||
|
||||
const tokenSet = await client.callback(
|
||||
authConfig.oidc.redirectUri,
|
||||
{ code },
|
||||
{ code_verifier: codeVerifier, nonce }
|
||||
);
|
||||
|
||||
return tokenSet;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and decode ID token claims
|
||||
*/
|
||||
export function getIdTokenClaims(tokenSet) {
|
||||
return tokenSet.claims();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user info from the userinfo endpoint
|
||||
*/
|
||||
export async function getUserInfo(accessToken) {
|
||||
const client = getClient();
|
||||
return await client.userinfo(accessToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh access token using refresh token
|
||||
*/
|
||||
export async function refreshTokens(refreshToken) {
|
||||
const client = getClient();
|
||||
return await client.refresh(refreshToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get end session URL for logout
|
||||
*/
|
||||
export function getEndSessionUrl(idTokenHint, postLogoutRedirectUri) {
|
||||
const client = getClient();
|
||||
return client.endSessionUrl({
|
||||
id_token_hint: idTokenHint,
|
||||
post_logout_redirect_uri: postLogoutRedirectUri,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate random state for CSRF protection
|
||||
*/
|
||||
export function generateState() {
|
||||
return generators.state();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate random nonce for ID token validation
|
||||
*/
|
||||
export function generateNonce() {
|
||||
return generators.nonce();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate code verifier for PKCE
|
||||
*/
|
||||
export function generateCodeVerifier() {
|
||||
return generators.codeVerifier();
|
||||
}
|
||||
|
||||
export default {
|
||||
initializeOIDC,
|
||||
getClient,
|
||||
getAuthorizationUrl,
|
||||
exchangeCode,
|
||||
getIdTokenClaims,
|
||||
getUserInfo,
|
||||
refreshTokens,
|
||||
getEndSessionUrl,
|
||||
generateState,
|
||||
generateNonce,
|
||||
generateCodeVerifier,
|
||||
};
|
||||
@@ -1,4 +1,32 @@
|
||||
services:
|
||||
# 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:
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
59
frontend/package-lock.json
generated
59
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
113
frontend/src/components/LoginPage.jsx
Normal file
113
frontend/src/components/LoginPage.jsx
Normal file
@@ -0,0 +1,113 @@
|
||||
// Login Page - shows when user is not authenticated
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { LogIn, Shield, Loader2, AlertCircle } from 'lucide-react';
|
||||
|
||||
export function LoginPage() {
|
||||
const { login, loading, error, authEnabled } = useAuth();
|
||||
|
||||
// Check for error params in URL (from OIDC callback)
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const errorParam = params.get('error');
|
||||
if (errorParam) {
|
||||
console.error('[Login] Auth error:', errorParam);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Get error from URL if present
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const urlError = urlParams.get('error');
|
||||
|
||||
const getErrorMessage = (err) => {
|
||||
switch (err) {
|
||||
case 'invalid_state':
|
||||
return 'Session expired. Please try again.';
|
||||
case 'invalid_nonce':
|
||||
return 'Security validation failed. Please try again.';
|
||||
case 'no_access':
|
||||
return 'You do not have access to this application. Contact your administrator.';
|
||||
case 'auth_failed':
|
||||
return 'Authentication failed. Please try again.';
|
||||
default:
|
||||
return err || 'An error occurred during login.';
|
||||
}
|
||||
};
|
||||
|
||||
const displayError = urlError || error;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-dark-950 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Loader2 className="w-8 h-8 text-orange-500 animate-spin mx-auto mb-4" />
|
||||
<p className="text-dark-400">Checking authentication...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-dark-950 flex items-center justify-center p-4">
|
||||
<div className="max-w-md w-full">
|
||||
{/* Logo/Header */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 rounded-xl bg-gradient-to-br from-orange-500 to-orange-600 flex items-center justify-center mx-auto mb-4">
|
||||
<span className="text-white font-bold text-2xl">C</span>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-white mb-2">Claude Web UI</h1>
|
||||
<p className="text-dark-400">Sign in to access Claude Code sessions</p>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{displayError && (
|
||||
<div className="mb-6 p-4 bg-red-500/10 border border-red-500/30 rounded-lg flex items-start gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-red-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-red-400 font-medium">Login Error</p>
|
||||
<p className="text-red-400/80 text-sm mt-1">{getErrorMessage(displayError)}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Login Card */}
|
||||
<div className="bg-dark-900 border border-dark-800 rounded-xl p-6">
|
||||
<div className="space-y-4">
|
||||
{/* SSO Info */}
|
||||
<div className="flex items-center gap-3 p-4 bg-dark-800/50 rounded-lg">
|
||||
<Shield className="w-8 h-8 text-orange-400" />
|
||||
<div>
|
||||
<p className="text-dark-200 font-medium">Single Sign-On</p>
|
||||
<p className="text-dark-500 text-sm">Sign in with your organization account</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Login Button */}
|
||||
<button
|
||||
onClick={() => login('/')}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-orange-600 hover:bg-orange-500 rounded-lg font-medium transition-colors text-white"
|
||||
>
|
||||
<LogIn className="w-5 h-5" />
|
||||
Sign in with SSO
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-6 pt-4 border-t border-dark-800">
|
||||
<p className="text-dark-500 text-xs text-center">
|
||||
Access restricted to authorized users with agent-admin or agent-users group membership.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Version */}
|
||||
<p className="text-dark-600 text-xs text-center mt-6">
|
||||
Claude Code Web UI v1.0
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LoginPage;
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { 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 && (
|
||||
|
||||
@@ -2,3 +2,4 @@ export { Header } from './Header';
|
||||
export { Sidebar } from './Sidebar';
|
||||
export { MessageList } from './MessageList';
|
||||
export { ChatInput } from './ChatInput';
|
||||
export { LoginPage } from './LoginPage';
|
||||
|
||||
197
frontend/src/contexts/AuthContext.jsx
Normal file
197
frontend/src/contexts/AuthContext.jsx
Normal file
@@ -0,0 +1,197 @@
|
||||
// Authentication Context - handles user auth state and OIDC flow
|
||||
|
||||
import { createContext, useContext, useState, useEffect, useCallback, useMemo } from 'react';
|
||||
|
||||
const AuthContext = createContext(null);
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
const [user, setUser] = useState(null);
|
||||
const [authEnabled, setAuthEnabled] = useState(true);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [expiresAt, setExpiresAt] = useState(null);
|
||||
|
||||
// Check auth status on mount
|
||||
useEffect(() => {
|
||||
checkAuthStatus();
|
||||
}, []);
|
||||
|
||||
// Set up token refresh interval
|
||||
useEffect(() => {
|
||||
console.log('[AuthContext] Token refresh effect - authEnabled:', authEnabled, 'user:', user?.email, 'expiresAt:', expiresAt);
|
||||
if (!authEnabled || !user || !expiresAt) {
|
||||
console.log('[AuthContext] Token refresh effect - skipping (missing data)');
|
||||
return;
|
||||
}
|
||||
|
||||
// Refresh 5 minutes before expiry
|
||||
const refreshTime = expiresAt - Date.now() - 5 * 60 * 1000;
|
||||
console.log('[AuthContext] Token refresh calculation:', {
|
||||
expiresAt,
|
||||
now: Date.now(),
|
||||
refreshTime,
|
||||
willRefreshIn: refreshTime > 0 ? `${Math.round(refreshTime / 1000)}s` : 'NOW'
|
||||
});
|
||||
|
||||
if (refreshTime <= 0) {
|
||||
// Token already expired or about to, refresh now
|
||||
console.log('[AuthContext] Token expired or about to, refreshing NOW');
|
||||
refreshToken();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[AuthContext] Setting refresh timer for ${Math.round(refreshTime / 1000)}s`);
|
||||
const timer = setTimeout(refreshToken, refreshTime);
|
||||
return () => clearTimeout(timer);
|
||||
}, [authEnabled, user, expiresAt]);
|
||||
|
||||
// Check current auth status
|
||||
const checkAuthStatus = useCallback(async () => {
|
||||
console.log('[AuthContext] checkAuthStatus called');
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await fetch('/auth/user', {
|
||||
credentials: 'include', // Send cookies
|
||||
});
|
||||
|
||||
console.log('[AuthContext] /auth/user response status:', res.status);
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
console.log('[AuthContext] /auth/user data:', JSON.stringify(data));
|
||||
console.log('[AuthContext] Setting user:', data.user?.email);
|
||||
console.log('[AuthContext] expiresAt from server:', data.expiresAt, 'Date.now():', Date.now());
|
||||
setUser(data.user);
|
||||
setAuthEnabled(data.authEnabled);
|
||||
setExpiresAt(data.expiresAt || null);
|
||||
setError(null);
|
||||
} else if (res.status === 401) {
|
||||
// Not authenticated
|
||||
console.log('[AuthContext] Not authenticated (401)');
|
||||
setUser(null);
|
||||
// Still need to know if auth is enabled
|
||||
const statusRes = await fetch('/auth/status', {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (statusRes.ok) {
|
||||
const statusData = await statusRes.json();
|
||||
setAuthEnabled(statusData.authEnabled);
|
||||
}
|
||||
} else {
|
||||
throw new Error('Failed to check auth status');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Auth] Status check failed:', err);
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Initiate login - redirect to backend login route
|
||||
const login = useCallback((returnTo = '/') => {
|
||||
window.location.href = `/auth/login?returnTo=${encodeURIComponent(returnTo)}`;
|
||||
}, []);
|
||||
|
||||
// Logout
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/auth/logout', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setUser(null);
|
||||
setExpiresAt(null);
|
||||
|
||||
// If there's an OIDC logout URL, redirect to it
|
||||
if (data.logoutUrl) {
|
||||
window.location.href = data.logoutUrl;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Auth] Logout failed:', err);
|
||||
// Even if logout fails, clear local state
|
||||
setUser(null);
|
||||
setExpiresAt(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Refresh token
|
||||
const refreshToken = useCallback(async () => {
|
||||
console.log('[AuthContext] refreshToken called');
|
||||
try {
|
||||
const res = await fetch('/auth/refresh', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
console.log('[AuthContext] /auth/refresh response status:', res.status);
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
console.log('[AuthContext] Token refreshed, new expiresAt:', data.expiresAt);
|
||||
setExpiresAt(data.expiresAt);
|
||||
} else if (res.status === 401) {
|
||||
// Refresh token missing or invalid - but don't log out the user
|
||||
// The session cookie is still valid, just the OIDC token refresh failed
|
||||
// User will be properly logged out when session expires and /auth/user returns 401
|
||||
console.log('[AuthContext] Refresh returned 401 - token refresh unavailable (this is OK, session may still be valid)');
|
||||
// Clear expiresAt to prevent repeated refresh attempts
|
||||
setExpiresAt(null);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Auth] Token refresh failed:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Derived state
|
||||
const isAuthenticated = useMemo(() => {
|
||||
// If auth is disabled, everyone is "authenticated"
|
||||
if (!authEnabled) return true;
|
||||
return !!user;
|
||||
}, [authEnabled, user]);
|
||||
|
||||
const isAdmin = useMemo(() => {
|
||||
return user?.isAdmin || false;
|
||||
}, [user]);
|
||||
|
||||
// Context value
|
||||
const value = useMemo(() => ({
|
||||
// State
|
||||
user,
|
||||
authEnabled,
|
||||
loading,
|
||||
error,
|
||||
isAuthenticated,
|
||||
isAdmin,
|
||||
expiresAt,
|
||||
|
||||
// Actions
|
||||
login,
|
||||
logout,
|
||||
refreshToken,
|
||||
checkAuthStatus,
|
||||
}), [
|
||||
user, authEnabled, loading, error, isAuthenticated, isAdmin, expiresAt,
|
||||
login, logout, refreshToken, checkAuthStatus,
|
||||
]);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={value}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within AuthProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export default AuthContext;
|
||||
@@ -1,7 +1,12 @@
|
||||
import { createContext, useContext, useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
||||
|
||||
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) => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user