From 1186cb1b5e253a707e631b88f8e31d2155d0193b Mon Sep 17 00:00:00 2001 From: Nikolas Syring Date: Thu, 18 Dec 2025 06:07:22 +0100 Subject: [PATCH] feat: Add OIDC authentication with Authentik integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .env.example | 3 + .prompts/001-oidc-auth-plan/SUMMARY.md | 402 +++++++++ .../completed/001-oidc-auth-plan.md | 253 ++++++ .prompts/001-oidc-auth-plan/oidc-auth-plan.md | 766 ++++++++++++++++++ backend/config/auth.js | 62 ++ backend/middleware/auth.js | 184 +++++ backend/package.json | 7 +- backend/routes/auth.js | 262 ++++++ backend/server.js | 155 +++- backend/utils/oidc.js | 146 ++++ docker-compose.yml | 69 +- frontend/nginx.conf | 41 +- frontend/package-lock.json | 59 ++ frontend/package.json | 1 + frontend/src/App.jsx | 37 +- frontend/src/components/ChatInput.jsx | 27 +- frontend/src/components/ChatPanel.jsx | 54 +- frontend/src/components/LoginPage.jsx | 113 +++ frontend/src/components/Sidebar.jsx | 62 +- frontend/src/components/index.js | 1 + frontend/src/contexts/AuthContext.jsx | 197 +++++ frontend/src/contexts/SessionContext.jsx | 50 +- frontend/src/hooks/useClaudeSession.js | 20 +- 23 files changed, 2884 insertions(+), 87 deletions(-) create mode 100644 .env.example create mode 100644 .prompts/001-oidc-auth-plan/SUMMARY.md create mode 100644 .prompts/001-oidc-auth-plan/completed/001-oidc-auth-plan.md create mode 100644 .prompts/001-oidc-auth-plan/oidc-auth-plan.md create mode 100644 backend/config/auth.js create mode 100644 backend/middleware/auth.js create mode 100644 backend/routes/auth.js create mode 100644 backend/utils/oidc.js create mode 100644 frontend/src/components/LoginPage.jsx create mode 100644 frontend/src/contexts/AuthContext.jsx diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..56cc03e --- /dev/null +++ b/.env.example @@ -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 diff --git a/.prompts/001-oidc-auth-plan/SUMMARY.md b/.prompts/001-oidc-auth-plan/SUMMARY.md new file mode 100644 index 0000000..c433bf4 --- /dev/null +++ b/.prompts/001-oidc-auth-plan/SUMMARY.md @@ -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= +OIDC_CLIENT_SECRET= +OIDC_REDIRECT_URI=https://agents.sneakercloud.de/auth/callback + +# Session +SESSION_SECRET= +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? diff --git a/.prompts/001-oidc-auth-plan/completed/001-oidc-auth-plan.md b/.prompts/001-oidc-auth-plan/completed/001-oidc-auth-plan.md new file mode 100644 index 0000000..6d29bdf --- /dev/null +++ b/.prompts/001-oidc-auth-plan/completed/001-oidc-auth-plan.md @@ -0,0 +1,253 @@ + +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 + + + +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 + + + +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 + + + +Save to: `.prompts/001-oidc-auth-plan/oidc-auth-plan.md` + +Structure the plan using this XML format: + +```xml + + + {One paragraph overview of the OIDC implementation approach} + + + + + Create OIDC Provider and Application in Authentik + + Create OAuth2/OIDC Provider in Authentik + Configure redirect URIs for agents.sneakercloud.de + Create Application and bind to Provider + Create groups: agent-admin, agent-users + Configure group claims in OIDC scope + + + Authentik OIDC app configured + Client ID and Secret generated + Groups created and mapped + + Admin access to Authentik + + Document the exact Authentik configuration steps. + Save Client ID/Secret to .env (not committed). + Test OIDC endpoints manually before proceeding. + + + + + Implement OIDC authentication in backend + + Install dependencies: openid-client, cookie-parser, express-session + Create auth module with OIDC client configuration + Implement /api/auth/login - redirect to Authentik + Implement /api/auth/callback - handle OIDC response + Implement /api/auth/logout - clear session + Implement /api/auth/me - return current user info + Create auth middleware for protected routes + Add session validation to WebSocket connections + + + backend/auth.js module + Session-based authentication with httpOnly cookies + Protected API routes + + Phase 1 complete (Authentik configured) + + 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. + + + + + Implement React authentication context and UI + + Create AuthContext with user state management + Create useAuth hook for components + Create LoginPage component + Implement protected route wrapper + Add login/logout buttons to Header + Show user info in UI + Add loading state during auth check + + + src/contexts/AuthContext.jsx + src/hooks/useAuth.js + src/components/LoginPage.jsx + src/components/ProtectedRoute.jsx + + Phase 2 complete (backend auth working) + + 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. + + + + + Implement group-based access control + + Extract groups from OIDC claims in backend + Add isAdmin flag to user context + Protect admin-only API routes + Show admin UI elements conditionally + Add user management UI for admins (future) + + + Group-based route protection + Admin-specific UI elements + + Phase 3 complete + + agent-admin: Full access, all hosts, all features + agent-users: Limited hosts, standard features + Check group membership on backend, pass to frontend. + + + + + Test, document, and polish the implementation + + Test full auth flow end-to-end + Test WebSocket reconnection after session refresh + Handle edge cases (expired sessions, revoked tokens) + Update BookStack documentation + Add session timeout warnings + + + Working OIDC authentication + Updated documentation + + Phase 4 complete + + + + + + OIDC with Authentik is well-documented pattern. + httpOnly cookies with redirect flow is industry standard. + openid-client is mature library for Node.js. + + + - Authentik admin access + - DNS/SSL already configured (agents.sneakercloud.de) + - Current app deployed and working + + + - Session storage: In-memory vs Redis? + - Token refresh strategy: Silent refresh vs re-login? + - Should WebSocket auth use same session or separate token? + + + - 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 + + + +``` + + + +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* +``` + + + +- 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 + diff --git a/.prompts/001-oidc-auth-plan/oidc-auth-plan.md b/.prompts/001-oidc-auth-plan/oidc-auth-plan.md new file mode 100644 index 0000000..7f69493 --- /dev/null +++ b/.prompts/001-oidc-auth-plan/oidc-auth-plan.md @@ -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= +OIDC_CLIENT_SECRET= +OIDC_REDIRECT_URI=https://agents.sneakercloud.de/auth/callback +SESSION_SECRET= +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 ( + + + + ); +} + +function AuthenticatedApp() { + const { user, isLoading } = useAuth(); + + if (isLoading) return ; + if (!user) return ; + + return ( + + + + ); +} +``` + +#### 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= +OIDC_CLIENT_SECRET= +OIDC_REDIRECT_URI=https://agents.sneakercloud.de/auth/callback + +# Session +SESSION_SECRET= +SESSION_DOMAIN=.sneakercloud.de +SESSION_SECURE=true +SESSION_MAX_AGE=86400000 # 24 hours + +# Redis +REDIS_URL=redis://localhost:6379 +REDIS_PASSWORD= + +# 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 diff --git a/backend/config/auth.js b/backend/config/auth.js new file mode 100644 index 0000000..24f50ec --- /dev/null +++ b/backend/config/auth.js @@ -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; diff --git a/backend/middleware/auth.js b/backend/middleware/auth.js new file mode 100644 index 0000000..4b539e0 --- /dev/null +++ b/backend/middleware/auth.js @@ -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, +}; diff --git a/backend/package.json b/backend/package.json index 54ed5a2..77191ea 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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" } } diff --git a/backend/routes/auth.js b/backend/routes/auth.js new file mode 100644 index 0000000..aa13196 --- /dev/null +++ b/backend/routes/auth.js @@ -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; diff --git a/backend/server.js b/backend/server.js index 42f832f..50ca575 100644 --- a/backend/server.js +++ b/backend/server.js @@ -7,10 +7,32 @@ import cors from 'cors'; import { existsSync, readFileSync, readdirSync, statSync, mkdirSync, writeFileSync } from 'fs'; import { join, basename, extname } from 'path'; import multer from 'multer'; +import session from 'express-session'; +import { createClient } from 'redis'; +import RedisStore from 'connect-redis'; +import cookieParser from 'cookie-parser'; + +// Auth modules +import { authConfig, validateConfig } from './config/auth.js'; +import { initializeOIDC } from './utils/oidc.js'; +import { requireAuth, optionalAuth, authenticateWebSocket } from './middleware/auth.js'; +import authRoutes from './routes/auth.js'; const app = express(); -app.use(cors()); + +// Trust proxy - required for secure cookies behind reverse proxy (NPM) +app.set('trust proxy', 1); + +// CORS configuration - allow credentials for cookies +app.use(cors({ + origin: authConfig.app.frontendUrl, + credentials: true, +})); app.use(express.json()); +app.use(cookieParser()); + +// Session store reference (set after Redis connection) +let sessionStore = null; const PORT = process.env.PORT || 3001; const HOST = process.env.HOST || '0.0.0.0'; @@ -137,8 +159,11 @@ function scanProjects(basePath, depth = 0, maxDepth = 1) { return projects; } -// REST endpoint to list hosts -app.get('/api/hosts', (req, res) => { +// Function to register API routes (called after session middleware is set up) +function registerApiRoutes() { + +// REST endpoint to list hosts (protected) +app.get('/api/hosts', requireAuth, (req, res) => { const hosts = Object.entries(hostsConfig.hosts).map(([id, host]) => ({ id, name: host.name, @@ -151,8 +176,8 @@ app.get('/api/hosts', (req, res) => { res.json({ hosts, defaultHost: hostsConfig.defaults?.host || 'neko' }); }); -// REST endpoint to list projects for a host -app.get('/api/projects', (req, res) => { +// REST endpoint to list projects for a host (protected) +app.get('/api/projects', requireAuth, (req, res) => { const hostId = req.query.host || hostsConfig.defaults?.host || 'neko'; const host = hostsConfig.hosts[hostId]; @@ -210,7 +235,7 @@ app.get('/api/health', (req, res) => { }); // Browse directories on a host (for directory picker) -app.get('/api/browse', async (req, res) => { +app.get('/api/browse', requireAuth, async (req, res) => { const hostId = req.query.host || hostsConfig.defaults?.host || 'neko'; const path = req.query.path || '~'; const host = hostsConfig.hosts[hostId]; @@ -304,7 +329,7 @@ app.get('/api/browse', async (req, res) => { }); // File upload endpoint -app.post('/api/upload/:sessionId', upload.array('files', 5), async (req, res) => { +app.post('/api/upload/:sessionId', requireAuth, upload.array('files', 5), async (req, res) => { try { if (!req.files || req.files.length === 0) { return res.status(400).json({ error: 'No files uploaded' }); @@ -443,12 +468,14 @@ function parseHistoryContent(content) { } // Get session history for a project (supports SSH hosts) -app.get('/api/history/:project', async (req, res) => { +app.get('/api/history/:project', requireAuth, async (req, res) => { + console.log(`[History] Request for project: ${req.params.project}, host: ${req.query.host}`); try { const projectPath = decodeURIComponent(req.params.project); const hostId = req.query.host; const host = hostId ? hostsConfig.hosts[hostId] : null; const isSSH = host?.connection?.type === 'ssh'; + console.log(`[History] Resolved - projectPath: ${projectPath}, hostId: ${hostId}, isSSH: ${isSSH}`); // Convert project path to Claude's folder naming convention const projectFolder = projectPath.replace(/\//g, '-'); @@ -459,8 +486,9 @@ app.get('/api/history/:project', async (req, res) => { const sshTarget = `${user}@${sshHost}`; const historyDir = `~/.claude/projects/${projectFolder}`; - // Find latest session file via SSH - const findCmd = `ls -t ${historyDir}/*.jsonl 2>/dev/null | grep -v agent- | head -1`; + // Find latest non-empty session file via SSH (skip agent files and empty files) + // Using a simpler approach: find non-empty files with find command + const findCmd = `find ${historyDir} -maxdepth 1 -name '*.jsonl' ! -name 'agent-*' -size +0 -printf '%T@ %p\\n' 2>/dev/null | sort -rn | head -1 | cut -d' ' -f2-`; const { execSync } = await import('child_process'); try { @@ -483,6 +511,7 @@ app.get('/api/history/:project', async (req, res) => { const sessionId = basename(latestFile).replace('.jsonl', ''); const messages = parseHistoryContent(content); + console.log(`[History] SSH - Returning ${messages.length} messages from session ${sessionId}`); return res.json({ messages, sessionId, source: 'ssh' }); } catch (sshErr) { console.error('SSH history fetch error:', sshErr.message); @@ -497,14 +526,16 @@ app.get('/api/history/:project', async (req, res) => { return res.json({ messages: [], sessionId: null }); } - // Find the most recent non-agent session file + // Find the most recent non-agent, non-empty session file const files = readdirSync(historyDir) .filter(f => f.endsWith('.jsonl') && !f.startsWith('agent-')) .map(f => ({ name: f, path: join(historyDir, f), - mtime: statSync(join(historyDir, f)).mtime + mtime: statSync(join(historyDir, f)).mtime, + size: statSync(join(historyDir, f)).size })) + .filter(f => f.size > 0) // Skip empty files .sort((a, b) => b.mtime - a.mtime); if (files.length === 0) { @@ -523,10 +554,27 @@ app.get('/api/history/:project', async (req, res) => { } }); -wss.on('connection', (ws, req) => { +} // End of registerApiRoutes function + +wss.on('connection', async (ws, req) => { const sessionId = uuidv4(); console.log(`[${sessionId}] New WebSocket connection`); + // Authenticate WebSocket connection + let wsUser = null; + if (authConfig.app.authEnabled && sessionStore) { + wsUser = await authenticateWebSocket(req, sessionStore); + if (!wsUser) { + console.log(`[${sessionId}] WebSocket authentication failed - closing connection`); + ws.close(1008, 'Unauthorized'); + return; + } + console.log(`[${sessionId}] WebSocket authenticated as: ${wsUser.email}`); + } else { + // Auth disabled - use anonymous user + wsUser = { id: 'anonymous', email: 'anonymous@local', groups: ['agent-admin'], isAdmin: true }; + } + let claudeProcess = null; let currentProject = null; let currentHostId = null; // Track current host for restart @@ -642,7 +690,7 @@ wss.on('connection', (ws, req) => { }); } - sessions.set(sessionId, { process: claudeProcess, project: projectPath, host: host, hostId: hostId }); + sessions.set(sessionId, { process: claudeProcess, project: projectPath, host: host, hostId: hostId, user: wsUser }); sendToClient('session_started', { sessionId, @@ -785,7 +833,8 @@ wss.on('connection', (ws, req) => { // Handle stderr claudeProcess.stderr.on('data', (data) => { const content = data.toString(); - if (DEBUG) console.log(`[${sessionId}] stderr:`, content); + // Always log stderr for SSH connections (exit code 255 debugging) + if (DEBUG || isSSH) console.log(`[${sessionId}] stderr:`, content); sendToClient('stderr', { content }); }); @@ -992,7 +1041,77 @@ wss.on('connection', (ws, req) => { }); }); -server.listen(PORT, HOST, () => { - console.log(`Claude Web UI Backend running on http://${HOST}:${PORT}`); - console.log(`WebSocket available at ws://${HOST}:${PORT}`); +// 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); }); diff --git a/backend/utils/oidc.js b/backend/utils/oidc.js new file mode 100644 index 0000000..38df174 --- /dev/null +++ b/backend/utils/oidc.js @@ -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, +}; diff --git a/docker-compose.yml b/docker-compose.yml index 6f1e7ce..80461a1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 4984900..db6af19 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -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; } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 503fe40..b4ad595 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 9f0ce1a..0d8dc2a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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": { diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index d47b859..5e4324b 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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 ( +
+
+ +

Loading...

+
+
+ ); +} + +// Auth wrapper - shows login or main app +function AuthenticatedApp() { + const { isAuthenticated, loading } = useAuth(); + + if (loading) { + return ; + } + + if (!isAuthenticated) { + return ; + } + return ( @@ -109,4 +134,12 @@ function App() { ); } +function App() { + return ( + + + + ); +} + export default App; diff --git a/frontend/src/components/ChatInput.jsx b/frontend/src/components/ChatInput.jsx index 4dbd6b6..0378144 100644 --- a/frontend/src/components/ChatInput.jsx +++ b/frontend/src/components/ChatInput.jsx @@ -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 )} -