Implement comprehensive user authentication with SSH key management (#43)

* Implement comprehensive user authentication system

- Add SSH-first authentication with password fallback
- Implement JWT token-based session management (24h expiry)
- Create browser-based SSH agent with key storage and signing
- Add challenge-response SSH authentication protocol
- Integrate PAM for system password authentication
- Build comprehensive authentication UI components
- Add SSH key manager for key generation and management
- Update middleware to support JWT tokens alongside existing auth
- Maintain backwards compatibility with existing HQ/remote auth
This commit is contained in:
Helmut Januschka 2025-06-24 00:31:13 +02:00 committed by GitHub
parent 24416d2c27
commit e9b395b726
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 3074 additions and 843 deletions

280
docs/authentication.md Normal file
View file

@ -0,0 +1,280 @@
# VibeTunnel Authentication System
VibeTunnel supports multiple authentication modes to balance security and convenience for different use cases.
## Authentication Modes
### 1. Default Mode (Password Authentication)
**Usage:** Start VibeTunnel without any auth flags
```bash
npm run dev
# or
./vibetunnel
```
**Behavior:**
- Shows login page with user avatar (on macOS)
- Requires system user password authentication
- Uses JWT tokens for session management
- SSH key functionality is hidden
**Best for:** Personal use with secure password authentication
### 2. SSH Key Mode
**Usage:** Enable SSH key authentication alongside password
```bash
npm run dev -- --enable-ssh-keys
# or
./vibetunnel --enable-ssh-keys
```
**Behavior:**
- Shows login page with both password and SSH key options
- Users can generate Ed25519 SSH keys in the browser
- SSH keys are stored securely in browser localStorage
- Optional password protection for private keys
- SSH keys work for both web and terminal authentication
**Best for:** Power users who prefer SSH key authentication
### 3. SSH Keys Only Mode
**Usage:** Disable password authentication, SSH keys only
```bash
./vibetunnel --disallow-user-password
# or
./vibetunnel --disallow-user-password --enable-ssh-keys # redundant, auto-enabled
```
**Behavior:**
- Shows login page with SSH key options only
- Password authentication form is hidden
- Automatically enables `--enable-ssh-keys`
- User avatar still displayed with "SSH key authentication required" message
- Most secure authentication mode
**Best for:** High-security environments, organizations requiring key-based auth
### 4. No Authentication Mode
**Usage:** Disable authentication completely
```bash
npm run dev -- --no-auth
# or
./vibetunnel --no-auth
```
**Behavior:**
- Bypasses login page entirely
- Direct access to dashboard
- No authentication required
- Auto-logs in as current system user
- **Overrides all other auth flags**
**Best for:** Local development, trusted networks, or demo environments
## User Avatar System
### macOS Integration
On macOS, VibeTunnel automatically displays the user's system profile picture:
- **Data Source:** Uses `dscl . -read /Users/$USER JPEGPhoto` to extract avatar
- **Format:** Converts hex data to base64 JPEG
- **Fallback:** Uses `Picture` attribute if JPEGPhoto unavailable
- **Display:** Shows in login form with welcome message
### Other Platforms
On non-macOS systems:
- Displays a generic SVG avatar icon
- Maintains consistent UI layout
- No system integration required
## Command Line Options
### Server Startup Flags
```bash
# Authentication options
--enable-ssh-keys Enable SSH key authentication UI and functionality
--disallow-user-password Disable password auth, SSH keys only (auto-enables --enable-ssh-keys)
--no-auth Disable authentication (auto-login as current user)
# Other options
--port <number> Server port (default: 4020)
--bind <address> Bind address (default: 0.0.0.0)
--debug Enable debug logging
```
### Example Commands
```bash
# Default password-only authentication
npm run dev
# Enable SSH keys alongside password
npm run dev -- --enable-ssh-keys
# SSH keys only (most secure)
./vibetunnel --disallow-user-password
# No authentication for local development (npm run dev uses this by default)
npm run dev -- --no-auth
# Production with SSH keys on custom port
./vibetunnel --enable-ssh-keys --port 8080
# High-security production (SSH keys only)
./vibetunnel --disallow-user-password --port 8080
```
## Security Considerations
### Password Authentication
- Uses system PAM authentication
- Validates against actual system user passwords
- JWT tokens expire after 24 hours
- Secure session management
### SSH Key Authentication
- Generates Ed25519 keys (most secure)
- Private keys stored in browser localStorage
- Optional password protection for private keys
- Keys work for both web and terminal access
- Challenge-response authentication flow
### No Authentication Mode
- **⚠️ Security Warning:** Only use in trusted environments
- Suitable for local development or demo purposes
- Not recommended for production or public networks
## Configuration API
### Frontend Configuration Endpoint
The frontend can query the server's authentication configuration:
```javascript
// GET /api/auth/config
{
"enableSSHKeys": false,
"disallowUserPassword": false,
"noAuth": false
}
```
This allows the UI to:
- Show/hide SSH key options dynamically
- Hide password form when disallowed
- Skip login page when no-auth is enabled
- Adapt interface based on server configuration
## SSH Key Management
### Key Generation
- **Algorithm:** Ed25519 (most secure and modern)
- **Storage:** Browser localStorage (encrypted if password-protected)
- **Format:** PEM format for compatibility
- **Naming:** User-defined names for organization
### Key Import
- Supports importing existing private keys
- PEM format required
- Automatic password detection
- Validation and error handling
### Key Usage
- Browser-based signing for web authentication
- Automatic terminal integration
- Challenge-response authentication
- No server-side key storage
## Implementation Details
### Authentication Flow
1. **Server startup** determines available auth modes
2. **Frontend queries** `/api/auth/config` for configuration
3. **UI renders** appropriate authentication options
4. **User authenticates** via chosen method
5. **JWT token issued** for session management
6. **Subsequent requests** use Bearer token authentication
### Avatar Implementation
```bash
# macOS avatar extraction
dscl . -read /Users/$USER JPEGPhoto | tail -1 | xxd -r -p > avatar.jpg
# Server endpoint
GET /api/auth/avatar/:userId
```
### File Structure
```
src/
├── server/
│ ├── middleware/auth.ts # Authentication middleware
│ ├── routes/auth.ts # Authentication routes
│ ├── services/auth-service.ts # JWT and user management
│ └── server.ts # Server configuration
└── client/
├── components/auth-login.ts # Login UI component
├── services/auth-client.ts # Frontend auth service
└── services/ssh-agent.ts # SSH key management
```
## Migration from Basic Auth
The new system replaces the previous basic auth implementation:
### Removed
- `--username` and `--password` flags
- `VIBETUNNEL_USERNAME` and `VIBETUNNEL_PASSWORD` environment variables
- HTTP Basic Authentication headers
- Static username/password validation
### Added
- System user authentication
- Configurable authentication modes
- SSH key generation and management
- User avatar integration
- JWT-based session management
## Troubleshooting
### Common Issues
**Login page shows briefly then disappears (no-auth mode)**
- This is expected behavior - the page quickly redirects to dashboard
**SSH section not showing**
- Ensure server started with `--enable-ssh-keys` flag
- Check browser console for configuration loading errors
**Avatar not displaying**
- macOS only feature - other platforms show generic icon
- Check user has profile picture set in System Preferences
**Authentication fails**
- Verify system password is correct
- Check server logs for detailed error messages
- Ensure proper permissions for PAM authentication
### Debug Mode
Enable debug logging for detailed authentication flow:
```bash
npm run dev -- --debug --enable-ssh-keys
```
This provides verbose logging of:
- Authentication attempts
- Token validation
- SSH key operations
- Configuration loading

View file

@ -0,0 +1,10 @@
{
"permissions": {
"allow": [
"Bash(git -C .. pull)",
"Bash(rg:*)",
"Bash(grep:*)"
],
"deny": []
}
}

809
web/package-lock.json generated
View file

@ -21,8 +21,10 @@
"@codemirror/view": "^6.28.0",
"@homebridge/node-pty-prebuilt-multiarch": "^0.12.0",
"@xterm/headless": "^5.5.0",
"authenticate-pam": "^1.0.5",
"chalk": "^4.1.2",
"express": "^4.19.2",
"jsonwebtoken": "^9.0.2",
"lit": "^3.3.0",
"mime-types": "^3.0.1",
"monaco-editor": "^0.52.2",
@ -35,6 +37,7 @@
"@eslint/js": "^9.29.0",
"@testing-library/dom": "^10.4.0",
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.10",
"@types/mime-types": "^3.0.1",
"@types/node": "^24.0.3",
"@types/supertest": "^6.0.3",
@ -345,91 +348,6 @@
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz",
"integrity": "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.5.tgz",
"integrity": "sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.5.tgz",
"integrity": "sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.5.tgz",
"integrity": "sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.5.tgz",
"integrity": "sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.5.tgz",
@ -447,329 +365,6 @@
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.5.tgz",
"integrity": "sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.5.tgz",
"integrity": "sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.5.tgz",
"integrity": "sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.5.tgz",
"integrity": "sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.5.tgz",
"integrity": "sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.5.tgz",
"integrity": "sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.5.tgz",
"integrity": "sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.5.tgz",
"integrity": "sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.5.tgz",
"integrity": "sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.5.tgz",
"integrity": "sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.5.tgz",
"integrity": "sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.5.tgz",
"integrity": "sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.5.tgz",
"integrity": "sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.5.tgz",
"integrity": "sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.5.tgz",
"integrity": "sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.5.tgz",
"integrity": "sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.5.tgz",
"integrity": "sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.5.tgz",
"integrity": "sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.5.tgz",
"integrity": "sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@eslint-community/eslint-utils": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz",
@ -1485,48 +1080,6 @@
"node": ">=12"
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.44.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.44.0.tgz",
"integrity": "sha512-xEiEE5oDW6tK4jXCAyliuntGR+amEMO7HLtdSshVuhFnKTYoeYMyXQK7pLouAJJj5KHdwdn87bfHAR2nSdNAUA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.44.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.44.0.tgz",
"integrity": "sha512-uNSk/TgvMbskcHxXYHzqwiyBlJ/lGcv8DaUfcnNwict8ba9GTTNxfn3/FAoFZYgkaXXAdrAA+SLyKplyi349Jw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.44.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.44.0.tgz",
"integrity": "sha512-VGF3wy0Eq1gcEIkSCr8Ke03CWT+Pm2yveKLaDvq51pPpZza3JX/ClxXOCmTYYq3us5MvEuNRTaeyFThCKRQhOA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.44.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.44.0.tgz",
@ -1541,230 +1094,6 @@
"darwin"
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.44.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.44.0.tgz",
"integrity": "sha512-u5AZzdQJYJXByB8giQ+r4VyfZP+walV+xHWdaFx/1VxsOn6eWJhK2Vl2eElvDJFKQBo/hcYIBg/jaKS8ZmKeNQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.44.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.44.0.tgz",
"integrity": "sha512-qC0kS48c/s3EtdArkimctY7h3nHicQeEUdjJzYVJYR3ct3kWSafmn6jkNCA8InbUdge6PVx6keqjk5lVGJf99g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.44.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.44.0.tgz",
"integrity": "sha512-x+e/Z9H0RAWckn4V2OZZl6EmV0L2diuX3QB0uM1r6BvhUIv6xBPL5mrAX2E3e8N8rEHVPwFfz/ETUbV4oW9+lQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.44.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.44.0.tgz",
"integrity": "sha512-1exwiBFf4PU/8HvI8s80icyCcnAIB86MCBdst51fwFmH5dyeoWVPVgmQPcKrMtBQ0W5pAs7jBCWuRXgEpRzSCg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.44.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.44.0.tgz",
"integrity": "sha512-ZTR2mxBHb4tK4wGf9b8SYg0Y6KQPjGpR4UWwTFdnmjB4qRtoATZ5dWn3KsDwGa5Z2ZBOE7K52L36J9LueKBdOQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.44.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.44.0.tgz",
"integrity": "sha512-GFWfAhVhWGd4r6UxmnKRTBwP1qmModHtd5gkraeW2G490BpFOZkFtem8yuX2NyafIP/mGpRJgTJ2PwohQkUY/Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-loongarch64-gnu": {
"version": "4.44.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.44.0.tgz",
"integrity": "sha512-xw+FTGcov/ejdusVOqKgMGW3c4+AgqrfvzWEVXcNP6zq2ue+lsYUgJ+5Rtn/OTJf7e2CbgTFvzLW2j0YAtj0Gg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-powerpc64le-gnu": {
"version": "4.44.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.44.0.tgz",
"integrity": "sha512-bKGibTr9IdF0zr21kMvkZT4K6NV+jjRnBoVMt2uNMG0BYWm3qOVmYnXKzx7UhwrviKnmK46IKMByMgvpdQlyJQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.44.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.44.0.tgz",
"integrity": "sha512-vV3cL48U5kDaKZtXrti12YRa7TyxgKAIDoYdqSIOMOFBXqFj2XbChHAtXquEn2+n78ciFgr4KIqEbydEGPxXgA==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.44.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.44.0.tgz",
"integrity": "sha512-TDKO8KlHJuvTEdfw5YYFBjhFts2TR0VpZsnLLSYmB7AaohJhM8ctDSdDnUGq77hUh4m/djRafw+9zQpkOanE2Q==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.44.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.44.0.tgz",
"integrity": "sha512-8541GEyktXaw4lvnGp9m84KENcxInhAt6vPWJ9RodsB/iGjHoMB2Pp5MVBCiKIRxrxzJhGCxmNzdu+oDQ7kwRA==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.44.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.44.0.tgz",
"integrity": "sha512-iUVJc3c0o8l9Sa/qlDL2Z9UP92UZZW1+EmQ4xfjTc1akr0iUFZNfxrXJ/R1T90h/ILm9iXEY6+iPrmYB3pXKjw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.44.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.44.0.tgz",
"integrity": "sha512-PQUobbhLTQT5yz/SPg116VJBgz+XOtXt8D1ck+sfJJhuEsMj2jSej5yTdp8CvWBSceu+WW+ibVL6dm0ptG5fcA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.44.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.44.0.tgz",
"integrity": "sha512-M0CpcHf8TWn+4oTxJfh7LQuTuaYeXGbk0eageVjQCKzYLsajWS/lFC94qlRqOlyC2KvRT90ZrfXULYmukeIy7w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.44.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.44.0.tgz",
"integrity": "sha512-3XJ0NQtMAXTWFW8FqZKcw3gOQwBtVWP/u8TpHP3CRPXD7Pd6s8lLdH3sHWh8vqKCyyiI8xW5ltJScQmBU9j7WA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.44.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.44.0.tgz",
"integrity": "sha512-Q2Mgwt+D8hd5FIPUuPDsvPR7Bguza6yTkJxspDGkZj7tBRn2y4KSWYuIXpftFSjBra76TbKerCV7rgFPQrn+wQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@testing-library/dom": {
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz",
@ -1891,6 +1220,17 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/jsonwebtoken": {
"version": "9.0.10",
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
"integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/ms": "*",
"@types/node": "*"
}
},
"node_modules/@types/methods": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
@ -1912,6 +1252,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/ms": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "24.0.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.3.tgz",
@ -2672,6 +2019,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/authenticate-pam": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/authenticate-pam/-/authenticate-pam-1.0.5.tgz",
"integrity": "sha512-zaPml3/19Sa3XLewuOoUNsxwnNz13mTNoO4Q09vr93cjTrH0dwXOU49Bcetk/XWl22bw9zO9WovSKkddGvBEsQ==",
"hasInstallScript": true,
"dependencies": {
"nan": "^2.3.3"
}
},
"node_modules/autoprefixer": {
"version": "10.4.21",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz",
@ -5483,10 +4839,32 @@
"dev": true,
"license": "MIT"
},
"node_modules/jsonwebtoken": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
"integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==",
"license": "MIT",
"dependencies": {
"jws": "^3.2.2",
"lodash.includes": "^4.3.0",
"lodash.isboolean": "^3.0.3",
"lodash.isinteger": "^4.0.4",
"lodash.isnumber": "^3.0.3",
"lodash.isplainobject": "^4.0.6",
"lodash.isstring": "^4.0.1",
"lodash.once": "^4.0.0",
"ms": "^2.1.1",
"semver": "^7.5.4"
},
"engines": {
"node": ">=12",
"npm": ">=6"
}
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
"integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
@ -5495,12 +4873,12 @@
}
},
"node_modules/jws": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz",
"integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==",
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
"integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.0",
"jwa": "^1.4.1",
"safe-buffer": "^5.0.1"
}
},
@ -5609,6 +4987,42 @@
"dev": true,
"license": "MIT"
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
"license": "MIT"
},
"node_modules/lodash.isinteger": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
"license": "MIT"
},
"node_modules/lodash.isnumber": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
"license": "MIT"
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"license": "MIT"
},
"node_modules/lodash.isstring": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
"license": "MIT"
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@ -5616,6 +5030,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT"
},
"node_modules/lodash.throttle": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz",
@ -5881,6 +5301,12 @@
"thenify-all": "^1.0.0"
}
},
"node_modules/nan": {
"version": "2.22.2",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.22.2.tgz",
"integrity": "sha512-DANghxFkS1plDdRsX0X9pm0Z6SJNN6gBdtXfanwoZ8hooC5gosGFSBGRYHUVPz1asKA/kMRqDRdHrluZ61SpBQ==",
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@ -8425,6 +7851,27 @@
"node": ">= 16"
}
},
"node_modules/web-push/node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/web-push/node_modules/jws": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz",
"integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.0",
"safe-buffer": "^5.0.1"
}
},
"node_modules/web-streams-polyfill": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",

View file

@ -30,8 +30,10 @@
"@codemirror/view": "^6.28.0",
"@homebridge/node-pty-prebuilt-multiarch": "^0.12.0",
"@xterm/headless": "^5.5.0",
"authenticate-pam": "^1.0.5",
"chalk": "^4.1.2",
"express": "^4.19.2",
"jsonwebtoken": "^9.0.2",
"lit": "^3.3.0",
"mime-types": "^3.0.1",
"monaco-editor": "^0.52.2",
@ -44,6 +46,7 @@
"@eslint/js": "^9.29.0",
"@testing-library/dom": "^10.4.0",
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.10",
"@types/mime-types": "^3.0.1",
"@types/node": "^24.0.3",
"@types/supertest": "^6.0.3",

View file

@ -24,7 +24,7 @@ const commands = [
// Add server watching if not client-only
if (watchServer) {
commands.push(['npx', ['tsx', 'watch', 'src/cli.ts']]);
commands.push(['npx', ['tsx', 'watch', 'src/cli.ts', '--no-auth']]);
}
// Set up esbuild contexts for watching

View file

@ -21,8 +21,11 @@ import './components/file-browser.js';
import './components/log-viewer.js';
import './components/notification-settings.js';
import './components/notification-status.js';
import './components/auth-login.js';
import './components/ssh-key-manager.js';
import type { SessionCard } from './components/session-card.js';
import { AuthClient } from './services/auth-client.js';
const logger = createLogger('app');
@ -37,13 +40,16 @@ export class VibeTunnelApp extends LitElement {
@state() private successMessage = '';
@state() private sessions: Session[] = [];
@state() private loading = false;
@state() private currentView: 'list' | 'session' = 'list';
@state() private currentView: 'list' | 'session' | 'auth' = 'auth';
@state() private selectedSessionId: string | null = null;
@state() private hideExited = this.loadHideExitedState();
@state() private showCreateModal = false;
@state() private showFileBrowser = false;
@state() private showNotificationSettings = false;
@state() private showSSHKeyManager = false;
@state() private isAuthenticated = false;
private initialLoadComplete = false;
private authClient = new AuthClient();
private hotReloadWs: WebSocket | null = null;
private errorTimeoutId: number | null = null;
@ -52,8 +58,7 @@ export class VibeTunnelApp extends LitElement {
connectedCallback() {
super.connectedCallback();
this.setupHotReload();
this.loadSessions();
this.startAutoRefresh();
this.checkAuthenticationStatus();
this.setupRouting();
this.setupKeyboardShortcuts();
this.setupNotificationHandlers();
@ -82,6 +87,63 @@ export class VibeTunnelApp extends LitElement {
window.addEventListener('keydown', this.handleKeyDown);
}
private async checkAuthenticationStatus() {
// Check if no-auth is enabled first
try {
const configResponse = await fetch('/api/auth/config');
if (configResponse.ok) {
const authConfig = await configResponse.json();
console.log('🔧 Auth config:', authConfig);
if (authConfig.noAuth) {
console.log('🔓 No auth required, bypassing authentication');
this.isAuthenticated = true;
this.currentView = 'list';
this.loadSessions();
this.startAutoRefresh();
return;
}
}
} catch (error) {
console.warn('⚠️ Could not fetch auth config:', error);
}
this.isAuthenticated = this.authClient.isAuthenticated();
console.log('🔐 Authentication status:', this.isAuthenticated);
if (this.isAuthenticated) {
this.currentView = 'list';
this.loadSessions();
this.startAutoRefresh();
} else {
this.currentView = 'auth';
}
}
private handleAuthSuccess() {
console.log('✅ Authentication successful');
this.isAuthenticated = true;
this.currentView = 'list';
this.loadSessions();
this.startAutoRefresh();
}
private async handleLogout() {
console.log('👋 Logging out');
await this.authClient.logout();
this.isAuthenticated = false;
this.currentView = 'auth';
this.sessions = [];
}
private handleShowSSHKeyManager() {
this.showSSHKeyManager = true;
}
private handleCloseSSHKeyManager() {
this.showSSHKeyManager = false;
}
private showError(message: string) {
// Clear any existing error timeout
if (this.errorTimeoutId !== null) {
@ -134,10 +196,15 @@ export class VibeTunnelApp extends LitElement {
this.loading = true;
}
try {
const response = await fetch('/api/sessions');
const headers = this.authClient.getAuthHeader();
const response = await fetch('/api/sessions', { headers });
if (response.ok) {
this.sessions = (await response.json()) as Session[];
this.clearError();
} else if (response.status === 401) {
// Authentication failed, redirect to login
this.handleLogout();
return;
} else {
this.showError('Failed to load sessions');
}
@ -381,18 +448,43 @@ export class VibeTunnelApp extends LitElement {
window.addEventListener('popstate', this.handlePopState.bind(this));
// Parse initial URL and set state
this.parseUrlAndSetState();
this.parseUrlAndSetState().catch(console.error);
}
private handlePopState = (_event: PopStateEvent) => {
// Handle browser back/forward navigation
this.parseUrlAndSetState();
this.parseUrlAndSetState().catch(console.error);
};
private parseUrlAndSetState() {
private async parseUrlAndSetState() {
const url = new URL(window.location.href);
const sessionId = url.searchParams.get('session');
// Check authentication status first (unless no-auth is enabled)
try {
const configResponse = await fetch('/api/auth/config');
if (configResponse.ok) {
const authConfig = await configResponse.json();
if (authConfig.noAuth) {
// Skip auth check for no-auth mode
} else if (!this.authClient.isAuthenticated()) {
this.currentView = 'auth';
this.selectedSessionId = null;
return;
}
} else if (!this.authClient.isAuthenticated()) {
this.currentView = 'auth';
this.selectedSessionId = null;
return;
}
} catch (_error) {
if (!this.authClient.isAuthenticated()) {
this.currentView = 'auth';
this.selectedSessionId = null;
return;
}
}
if (sessionId) {
this.selectedSessionId = sessionId;
this.currentView = 'session';
@ -507,44 +599,56 @@ export class VibeTunnelApp extends LitElement {
: ''}
<!-- Main content -->
${this.currentView === 'session' && this.selectedSessionId
? keyed(
this.selectedSessionId,
html`
<session-view
.session=${this.sessions.find((s) => s.id === this.selectedSessionId)}
@navigate-to-list=${this.handleNavigateToList}
></session-view>
`
)
: html`
<div>
<app-header
.sessions=${this.sessions}
.hideExited=${this.hideExited}
@create-session=${this.handleCreateSession}
@hide-exited-change=${this.handleHideExitedChange}
@kill-all-sessions=${this.handleKillAll}
@clean-exited-sessions=${this.handleCleanExited}
@open-file-browser=${() => (this.showFileBrowser = true)}
@open-notification-settings=${this.handleShowNotificationSettings}
></app-header>
<session-list
.sessions=${this.sessions}
.loading=${this.loading}
.hideExited=${this.hideExited}
.showCreateModal=${this.showCreateModal}
@session-killed=${this.handleSessionKilled}
@session-created=${this.handleSessionCreated}
@create-modal-close=${this.handleCreateModalClose}
@refresh=${this.handleRefresh}
@error=${this.handleError}
@hide-exited-change=${this.handleHideExitedChange}
@kill-all-sessions=${this.handleKillAll}
@navigate-to-session=${this.handleNavigateToSession}
></session-list>
</div>
`}
${this.currentView === 'auth'
? html`
<auth-login
.authClient=${this.authClient}
@auth-success=${this.handleAuthSuccess}
@show-ssh-key-manager=${this.handleShowSSHKeyManager}
></auth-login>
`
: this.currentView === 'session' && this.selectedSessionId
? keyed(
this.selectedSessionId,
html`
<session-view
.session=${this.sessions.find((s) => s.id === this.selectedSessionId)}
@navigate-to-list=${this.handleNavigateToList}
></session-view>
`
)
: html`
<div>
<app-header
.sessions=${this.sessions}
.hideExited=${this.hideExited}
.currentUser=${this.authClient.getCurrentUser()?.userId || null}
.authMethod=${this.authClient.getCurrentUser()?.authMethod || null}
@create-session=${this.handleCreateSession}
@hide-exited-change=${this.handleHideExitedChange}
@kill-all-sessions=${this.handleKillAll}
@clean-exited-sessions=${this.handleCleanExited}
@open-file-browser=${() => (this.showFileBrowser = true)}
@open-notification-settings=${this.handleShowNotificationSettings}
@logout=${this.handleLogout}
></app-header>
<session-list
.sessions=${this.sessions}
.loading=${this.loading}
.hideExited=${this.hideExited}
.showCreateModal=${this.showCreateModal}
.authClient=${this.authClient}
@session-killed=${this.handleSessionKilled}
@session-created=${this.handleSessionCreated}
@create-modal-close=${this.handleCreateModalClose}
@refresh=${this.handleRefresh}
@error=${this.handleError}
@hide-exited-change=${this.handleHideExitedChange}
@kill-all-sessions=${this.handleKillAll}
@navigate-to-session=${this.handleNavigateToSession}
></session-list>
</div>
`}
<!-- File Browser Modal -->
<file-browser
@ -564,8 +668,15 @@ export class VibeTunnelApp extends LitElement {
@error=${(e: CustomEvent) => this.showError(e.detail)}
></notification-settings>
<!-- SSH Key Manager Modal -->
<ssh-key-manager
.visible=${this.showSSHKeyManager}
.sshAgent=${this.authClient.getSSHAgent()}
@close=${this.handleCloseSSHKeyManager}
></ssh-key-manager>
<!-- Version and logs link in bottom right -->
<div class="fixed bottom-4 right-4 text-dark-text-secondary text-xs font-mono">
<div class="fixed bottom-4 right-4 text-dark-text-muted text-xs font-mono">
<a href="/logs" class="hover:text-dark-text transition-colors">Logs</a>
<span class="ml-2">v${VERSION}</span>
</div>

View file

@ -25,7 +25,10 @@ export class AppHeader extends LitElement {
@property({ type: Array }) sessions: Session[] = [];
@property({ type: Boolean }) hideExited = true;
@property({ type: String }) currentUser: string | null = null;
@property({ type: String }) authMethod: string | null = null;
@state() private killingAll = false;
@state() private showUserMenu = false;
private handleCreateSession(e: MouseEvent) {
// Capture button position for view transition
@ -41,6 +44,32 @@ export class AppHeader extends LitElement {
this.dispatchEvent(new CustomEvent('create-session'));
}
private handleLogout() {
this.showUserMenu = false;
this.dispatchEvent(new CustomEvent('logout'));
}
private toggleUserMenu() {
this.showUserMenu = !this.showUserMenu;
}
private handleClickOutside = (e: Event) => {
const target = e.target as HTMLElement;
if (!target.closest('.user-menu-container')) {
this.showUserMenu = false;
}
};
connectedCallback() {
super.connectedCallback();
document.addEventListener('click', this.handleClickOutside);
}
disconnectedCallback() {
super.disconnectedCallback();
document.removeEventListener('click', this.handleClickOutside);
}
private handleKillAll() {
if (this.killingAll) return;
@ -254,6 +283,47 @@ export class AppHeader extends LitElement {
>
Create Session
</button>
${this.currentUser
? html`
<div class="user-menu-container relative">
<button
class="btn-ghost font-mono text-xs text-dark-text flex items-center gap-1"
@click=${this.toggleUserMenu}
title="User menu"
>
<span>${this.currentUser}</span>
<svg
width="10"
height="10"
viewBox="0 0 10 10"
fill="currentColor"
class="transition-transform ${this.showUserMenu ? 'rotate-180' : ''}"
>
<path d="M5 7L1 3h8z" />
</svg>
</button>
${this.showUserMenu
? html`
<div
class="absolute right-0 top-full mt-1 bg-dark-surface border border-dark-border rounded shadow-lg py-1 z-50 min-w-32"
>
<div
class="px-3 py-2 text-xs text-dark-text-muted border-b border-dark-border"
>
${this.authMethod || 'authenticated'}
</div>
<button
class="w-full text-left px-3 py-2 text-xs font-mono text-status-warning hover:bg-dark-bg-secondary hover:text-status-error"
@click=${this.handleLogout}
>
Logout
</button>
</div>
`
: ''}
</div>
`
: ''}
</div>
</div>
</div>

View file

@ -0,0 +1,295 @@
import { LitElement, html } from 'lit';
import { customElement, state, property } from 'lit/decorators.js';
import { AuthClient } from '../services/auth-client.js';
import './terminal-icon.js';
@customElement('auth-login')
export class AuthLogin extends LitElement {
// Disable shadow DOM to use Tailwind
createRenderRoot() {
return this;
}
@property({ type: Object }) authClient!: AuthClient;
@state() private loading = false;
@state() private error = '';
@state() private success = '';
@state() private currentUserId = '';
@state() private loginPassword = '';
@state() private userAvatar = '';
@state() private authConfig = {
enableSSHKeys: false,
disallowUserPassword: false,
noAuth: false,
};
async connectedCallback() {
super.connectedCallback();
console.log('🔌 Auth login component connected');
await this.loadUserInfo();
}
private async loadUserInfo() {
try {
// Load auth configuration first
try {
const configResponse = await fetch('/api/auth/config');
if (configResponse.ok) {
this.authConfig = await configResponse.json();
console.log('⚙️ Auth config loaded:', this.authConfig);
} else {
console.warn('⚠️ Failed to load auth config, using defaults:', configResponse.status);
}
} catch (error) {
console.error('❌ Error loading auth config:', error);
}
this.currentUserId = await this.authClient.getCurrentSystemUser();
console.log('👤 Current user:', this.currentUserId);
// Load user avatar
this.userAvatar = await this.authClient.getUserAvatar(this.currentUserId);
console.log('🖼️ User avatar loaded');
// If no auth required, auto-login
if (this.authConfig.noAuth) {
console.log('🔓 No auth required, auto-logging in');
this.dispatchEvent(
new CustomEvent('auth-success', {
detail: {
success: true,
userId: this.currentUserId,
authMethod: 'no-auth',
},
})
);
}
} catch (_error) {
this.error = 'Failed to load user information';
}
}
private async handlePasswordLogin(e: Event) {
e.preventDefault();
if (this.loading) return;
console.log('🔐 Attempting password authentication...');
this.loading = true;
this.error = '';
try {
const result = await this.authClient.authenticateWithPassword(
this.currentUserId,
this.loginPassword
);
console.log('🎫 Password auth result:', result);
if (result.success) {
this.loginPassword = '';
this.dispatchEvent(new CustomEvent('auth-success', { detail: result }));
} else {
this.error = result.error || 'Password authentication failed';
}
} catch (_error) {
this.error = 'Password authentication failed';
} finally {
this.loading = false;
}
}
private async handleSSHKeyAuth() {
if (this.loading) return;
console.log('🔐 Attempting SSH key authentication...');
this.loading = true;
this.error = '';
try {
const authResult = await this.authClient.authenticate(this.currentUserId);
console.log('🎯 SSH auth result:', authResult);
if (authResult.success) {
this.dispatchEvent(new CustomEvent('auth-success', { detail: authResult }));
} else {
this.error =
authResult.error || 'SSH key authentication failed. Please try password login.';
}
} catch (error) {
console.error('SSH key authentication error:', error);
this.error = 'SSH key authentication failed';
} finally {
this.loading = false;
}
}
private handleShowSSHKeyManager() {
this.dispatchEvent(new CustomEvent('show-ssh-key-manager'));
}
render() {
console.log(
'🔍 Rendering auth login',
'enableSSHKeys:',
this.authConfig.enableSSHKeys,
'noAuth:',
this.authConfig.noAuth
);
return html`
<div class="auth-container">
<div class="w-full max-w-md">
<div class="auth-header">
<div class="flex items-center gap-3 justify-center mb-2">
<terminal-icon size="48"></terminal-icon>
<h2 class="auth-title">VibeTunnel</h2>
</div>
<p class="auth-subtitle">Please authenticate to continue</p>
</div>
${this.error
? html`
<div class="bg-status-error text-dark-bg px-4 py-2 rounded mb-4 font-mono text-sm">
${this.error}
<button
@click=${() => (this.error = '')}
class="ml-2 text-dark-bg hover:text-dark-text"
>
</button>
</div>
`
: ''}
${this.success
? html`
<div
class="bg-status-success text-dark-bg px-4 py-2 rounded mb-4 font-mono text-sm"
>
${this.success}
<button
@click=${() => (this.success = '')}
class="ml-2 text-dark-bg hover:text-dark-text"
>
</button>
</div>
`
: ''}
<div class="auth-form">
${!this.authConfig.disallowUserPassword
? html`
<!-- Password Login Section (Primary) -->
<div class="ssh-key-item">
${this.userAvatar
? html`
<div class="flex flex-col items-center mb-6">
<img
src="${this.userAvatar}"
alt="User Avatar"
class="w-20 h-20 rounded-full border-2 border-dark-border mb-3"
/>
<p class="text-dark-text text-sm">
${this.currentUserId
? `Welcome back, ${this.currentUserId}`
: 'Please authenticate to continue'}
</p>
</div>
`
: ''}
<form @submit=${this.handlePasswordLogin} class="space-y-4">
<div>
<label class="form-label">Password</label>
<input
type="password"
class="input-field"
placeholder="Enter your system password"
.value=${this.loginPassword}
@input=${(e: Event) =>
(this.loginPassword = (e.target as HTMLInputElement).value)}
?disabled=${this.loading}
required
/>
</div>
<button
type="submit"
class="btn-primary w-full"
?disabled=${this.loading || !this.loginPassword}
>
${this.loading ? 'Authenticating...' : 'Login with Password'}
</button>
</form>
</div>
`
: ''}
${this.authConfig.disallowUserPassword && this.userAvatar
? html`
<!-- Avatar for SSH-only mode -->
<div class="ssh-key-item">
<div class="flex flex-col items-center mb-6">
<img
src="${this.userAvatar}"
alt="User Avatar"
class="w-20 h-20 rounded-full border-2 border-dark-border mb-3"
/>
<p class="text-dark-text text-sm">
${this.currentUserId
? `Welcome back, ${this.currentUserId}`
: 'Please authenticate to continue'}
</p>
<p class="text-dark-text-muted text-xs mt-2">
SSH key authentication required
</p>
</div>
</div>
`
: ''}
${this.authConfig.enableSSHKeys === true
? html`
<!-- Divider (only show if password auth is also available) -->
${!this.authConfig.disallowUserPassword
? html`
<div class="auth-divider">
<span>or</span>
</div>
`
: ''}
<!-- SSH Key Management Section -->
<div class="ssh-key-item">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-2">
<div class="w-2 h-2 rounded-full bg-accent-green"></div>
<span class="font-mono text-sm">SSH Key Management</span>
</div>
<button class="btn-ghost text-xs" @click=${this.handleShowSSHKeyManager}>
Manage Keys
</button>
</div>
<div class="space-y-3">
<div class="bg-dark-bg border border-dark-border rounded p-3">
<p class="text-dark-text-muted text-xs mb-2">
Generate SSH keys for browser-based authentication
</p>
<p class="text-dark-text-muted text-xs">
💡 SSH keys work in both browser and terminal
</p>
</div>
<button
class="btn-secondary w-full"
@click=${this.handleSSHKeyAuth}
?disabled=${this.loading}
>
${this.loading ? 'Authenticating...' : 'Login with SSH Key'}
</button>
</div>
</div>
`
: ''}
</div>
</div>
</div>
`;
}
}

View file

@ -12,6 +12,7 @@ import { LitElement, html } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { createRef, ref } from 'lit/directives/ref.js';
import type { Session } from './session-list.js';
import { AuthClient } from '../services/auth-client.js';
import { createLogger } from '../utils/logger.js';
import {
getFileIcon,
@ -107,6 +108,7 @@ export class FileBrowser extends LitElement {
private editorRef = createRef<HTMLElement>();
private pathInputRef = createRef<HTMLInputElement>();
private authClient = new AuthClient();
async connectedCallback() {
super.connectedCallback();
@ -144,7 +146,9 @@ export class FileBrowser extends LitElement {
const url = `/api/fs/browse?${params}`;
logger.debug(`loading directory: ${dirPath}`);
logger.debug(`fetching URL: ${url}`);
const response = await fetch(url);
const response = await fetch(url, {
headers: { ...this.authClient.getAuthHeader() },
});
logger.debug(`response status: ${response.status}`);
if (response.ok) {
@ -188,7 +192,9 @@ export class FileBrowser extends LitElement {
logger.debug(`loading preview for file: ${file.name}`);
logger.debug(`file path: ${file.path}`);
const response = await fetch(`/api/fs/preview?path=${encodeURIComponent(file.path)}`);
const response = await fetch(`/api/fs/preview?path=${encodeURIComponent(file.path)}`, {
headers: { ...this.authClient.getAuthHeader() },
});
if (response.ok) {
this.preview = await response.json();
this.requestUpdate(); // Trigger re-render to initialize Monaco if needed
@ -211,8 +217,12 @@ export class FileBrowser extends LitElement {
try {
// Load both the unified diff and the full content for Monaco
const [diffResponse, contentResponse] = await Promise.all([
fetch(`/api/fs/diff?path=${encodeURIComponent(file.path)}`),
fetch(`/api/fs/diff-content?path=${encodeURIComponent(file.path)}`),
fetch(`/api/fs/diff?path=${encodeURIComponent(file.path)}`, {
headers: { ...this.authClient.getAuthHeader() },
}),
fetch(`/api/fs/diff-content?path=${encodeURIComponent(file.path)}`, {
headers: { ...this.authClient.getAuthHeader() },
}),
]);
if (diffResponse.ok) {

View file

@ -1,5 +1,6 @@
import { LitElement, html, TemplateResult } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import { AuthClient } from '../services/auth-client.js';
interface LogEntry {
timestamp: string;
@ -28,6 +29,7 @@ export class LogViewer extends LitElement {
private refreshInterval?: number;
private isFirstLoad = true;
private authClient = new AuthClient();
override connectedCallback(): void {
super.connectedCallback();
@ -46,14 +48,18 @@ export class LogViewer extends LitElement {
private async loadLogs(): Promise<void> {
try {
// Get log info
const infoResponse = await fetch('/api/logs/info');
const infoResponse = await fetch('/api/logs/info', {
headers: { ...this.authClient.getAuthHeader() },
});
if (infoResponse.ok) {
const info = await infoResponse.json();
this.logSize = info.sizeHuman || '';
}
// Get raw logs
const response = await fetch('/api/logs/raw');
const response = await fetch('/api/logs/raw', {
headers: { ...this.authClient.getAuthHeader() },
});
if (!response.ok) {
throw new Error('Failed to load logs');
}
@ -171,7 +177,10 @@ export class LogViewer extends LitElement {
}
try {
const response = await fetch('/api/logs/clear', { method: 'DELETE' });
const response = await fetch('/api/logs/clear', {
method: 'DELETE',
headers: { ...this.authClient.getAuthHeader() },
});
if (!response.ok) {
throw new Error('Failed to clear logs');
}
@ -184,7 +193,9 @@ export class LogViewer extends LitElement {
private async downloadLogs(): Promise<void> {
try {
const response = await fetch('/api/logs/raw');
const response = await fetch('/api/logs/raw', {
headers: { ...this.authClient.getAuthHeader() },
});
if (!response.ok) {
throw new Error('Failed to download logs');
}

View file

@ -13,6 +13,7 @@
import { LitElement, html } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import type { Session } from '../../shared/types.js';
import type { AuthClient } from '../services/auth-client.js';
import { createLogger } from '../utils/logger.js';
import { copyToClipboard } from '../utils/path-utils.js';
@ -29,6 +30,7 @@ export class SessionCard extends LitElement {
}
@property({ type: Object }) session!: Session;
@property({ type: Object }) authClient!: AuthClient;
@state() private killing = false;
@state() private killingFrame = 0;
@state() private isActive = false;
@ -131,6 +133,9 @@ export class SessionCard extends LitElement {
const response = await fetch(endpoint, {
method: 'DELETE',
headers: {
...this.authClient.getAuthHeader(),
},
});
if (!response.ok) {

View file

@ -15,6 +15,7 @@ import { LitElement, html, PropertyValues } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import './file-browser.js';
import type { Session } from './session-list.js';
import type { AuthClient } from '../services/auth-client.js';
import { createLogger } from '../utils/logger.js';
const logger = createLogger('session-create-form');
@ -40,6 +41,7 @@ export class SessionCreateForm extends LitElement {
@property({ type: String }) sessionName = '';
@property({ type: Boolean }) disabled = false;
@property({ type: Boolean }) visible = false;
@property({ type: Object }) authClient!: AuthClient;
@state() private isCreating = false;
@state() private showFileBrowser = false;
@ -220,7 +222,10 @@ export class SessionCreateForm extends LitElement {
try {
const response = await fetch('/api/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
...this.authClient.getAuthHeader(),
},
body: JSON.stringify(sessionData),
});
@ -388,7 +393,7 @@ export class SessionCreateForm extends LitElement {
<!-- Quick Start Section -->
<div class="mb-6">
<label class="form-label text-dark-text-secondary uppercase text-xs tracking-wider"
<label class="form-label text-dark-text-muted uppercase text-xs tracking-wider"
>Quick Start</label
>
<div class="grid grid-cols-2 gap-3 mt-3">

View file

@ -20,6 +20,7 @@ import { LitElement, html } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
import type { Session } from '../../shared/types.js';
import type { AuthClient } from '../services/auth-client.js';
import './session-create-form.js';
import './session-card.js';
import { createLogger } from '../utils/logger.js';
@ -40,6 +41,7 @@ export class SessionList extends LitElement {
@property({ type: Boolean }) loading = false;
@property({ type: Boolean }) hideExited = true;
@property({ type: Boolean }) showCreateModal = false;
@property({ type: Object }) authClient!: AuthClient;
@state() private cleaningExited = false;
private previousRunningCount = 0;
@ -93,6 +95,9 @@ export class SessionList extends LitElement {
try {
const response = await fetch('/api/cleanup-exited', {
method: 'POST',
headers: {
...this.authClient.getAuthHeader(),
},
});
if (response.ok) {
@ -226,6 +231,7 @@ export class SessionList extends LitElement {
(session) => html`
<session-card
.session=${session}
.authClient=${this.authClient}
@session-select=${this.handleSessionSelect}
@session-killed=${this.handleSessionKilled}
@session-kill-error=${this.handleSessionKillError}
@ -238,6 +244,7 @@ export class SessionList extends LitElement {
<session-create-form
.visible=${this.showCreateModal}
.authClient=${this.authClient}
@session-created=${(e: CustomEvent) =>
this.dispatchEvent(new CustomEvent('session-created', { detail: e.detail }))}
@cancel=${() => this.dispatchEvent(new CustomEvent('create-modal-close'))}

View file

@ -26,6 +26,7 @@ import {
COMMON_TERMINAL_WIDTHS,
} from '../utils/terminal-preferences.js';
import { createLogger } from '../utils/logger.js';
import { AuthClient } from '../services/auth-client.js';
const logger = createLogger('session-view');
@ -59,6 +60,7 @@ export class SessionView extends LitElement {
@state() private terminalFontSize = 14;
private preferencesManager = TerminalPreferencesManager.getInstance();
private authClient = new AuthClient();
@state() private reconnectCount = 0;
@state() private ctrlSequence: string[] = [];
@ -322,7 +324,15 @@ export class SessionView extends LitElement {
this.streamConnection = null;
}
const streamUrl = `/api/sessions/${this.session.id}/stream`;
// Get auth client from the main app
const authClient = new AuthClient();
const user = authClient.getCurrentUser();
// Build stream URL with auth token as query parameter (EventSource doesn't support headers)
let streamUrl = `/api/sessions/${this.session.id}/stream`;
if (user?.token) {
streamUrl += `?token=${encodeURIComponent(user.token)}`;
}
// Use CastConverter to connect terminal to stream with reconnection tracking
const connection = CastConverter.connectToStream(this.terminal, streamUrl);
@ -484,6 +494,7 @@ export class SessionView extends LitElement {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...this.authClient.getAuthHeader(),
},
body: JSON.stringify(body),
});
@ -586,7 +597,10 @@ export class SessionView extends LitElement {
const response = await fetch(`/api/sessions/${this.session.id}/resize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
...this.authClient.getAuthHeader(),
},
body: JSON.stringify({ cols: cols, rows: rows }),
});
@ -950,6 +964,7 @@ export class SessionView extends LitElement {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...this.authClient.getAuthHeader(),
},
body: JSON.stringify(body),
});

View file

@ -0,0 +1,432 @@
import { LitElement, html } from 'lit';
import { customElement, state, property } from 'lit/decorators.js';
import { BrowserSSHAgent } from '../services/ssh-agent.js';
interface SSHKey {
id: string;
name: string;
publicKey: string;
algorithm: 'Ed25519';
encrypted: boolean;
fingerprint: string;
createdAt: string;
}
@customElement('ssh-key-manager')
export class SSHKeyManager extends LitElement {
// Disable shadow DOM to use Tailwind
createRenderRoot() {
return this;
}
@property({ type: Object }) sshAgent!: BrowserSSHAgent;
@property({ type: Boolean }) visible = false;
@state() private keys: SSHKey[] = [];
@state() private loading = false;
@state() private error = '';
@state() private success = '';
@state() private showAddForm = false;
@state() private newKeyName = '';
@state() private newKeyPassword = '';
@state() private importKeyName = '';
@state() private importKeyContent = '';
@state() private showInstructions = false;
@state() private instructionsKeyId = '';
connectedCallback() {
super.connectedCallback();
this.refreshKeys();
}
private refreshKeys() {
this.keys = this.sshAgent.listKeys() as SSHKey[];
}
private async handleGenerateKey() {
if (!this.newKeyName.trim()) {
this.error = 'Please enter a key name';
return;
}
this.loading = true;
this.error = '';
try {
const result = await this.sshAgent.generateKeyPair(
this.newKeyName,
this.newKeyPassword || undefined
);
// Automatically download the private key
this.downloadPrivateKey(result.privateKeyPEM, this.newKeyName);
this.success = `SSH key "${this.newKeyName}" generated successfully. Private key downloaded.`;
this.newKeyName = '';
this.newKeyPassword = '';
this.showAddForm = false;
this.showInstructions = true;
this.instructionsKeyId = result.keyId;
this.refreshKeys();
console.log('Generated key ID:', result.keyId);
} catch (error) {
this.error = `Failed to generate key: ${error}`;
} finally {
this.loading = false;
}
}
private downloadPrivateKey(privateKeyPEM: string, keyName: string) {
const blob = new Blob([privateKeyPEM], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${keyName.replace(/\s+/g, '_')}_private.pem`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
private async handleImportKey() {
if (!this.importKeyName.trim() || !this.importKeyContent.trim()) {
this.error = 'Please enter both key name and private key content';
return;
}
this.loading = true;
this.error = '';
try {
const keyId = await this.sshAgent.addKey(this.importKeyName, this.importKeyContent);
this.success = `SSH key "${this.importKeyName}" imported successfully`;
this.importKeyName = '';
this.importKeyContent = '';
this.showAddForm = false;
this.refreshKeys();
console.log('Imported key ID:', keyId);
} catch (error) {
this.error = `Failed to import key: ${error}`;
} finally {
this.loading = false;
}
}
private handleClose() {
this.dispatchEvent(new CustomEvent('close'));
}
private handleRemoveKey(keyId: string, keyName: string) {
if (confirm(`Are you sure you want to remove the SSH key "${keyName}"?`)) {
this.sshAgent.removeKey(keyId);
this.success = `SSH key "${keyName}" removed successfully`;
this.refreshKeys();
}
}
private handleDownloadPublicKey(keyId: string, keyName: string) {
const publicKey = this.sshAgent.getPublicKey(keyId);
if (publicKey) {
const blob = new Blob([publicKey], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${keyName.replace(/\s+/g, '_')}_public.pub`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
}
render() {
if (!this.visible) return html``;
return html`
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div
class="bg-dark-bg border border-dark-border rounded-lg p-6 w-full max-w-4xl max-h-[80vh] overflow-y-auto"
>
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-mono text-dark-text">SSH Key Manager</h2>
<button @click=${this.handleClose} class="text-dark-text-muted hover:text-dark-text">
</button>
</div>
${this.error
? html`
<div class="bg-status-error text-dark-bg px-4 py-2 rounded mb-4 font-mono text-sm">
${this.error}
<button
@click=${() => (this.error = '')}
class="ml-2 text-dark-bg hover:text-dark-text"
>
</button>
</div>
`
: ''}
${this.success
? html`
<div
class="bg-status-success text-dark-bg px-4 py-2 rounded mb-4 font-mono text-sm"
>
${this.success}
<button
@click=${() => (this.success = '')}
class="ml-2 text-dark-bg hover:text-dark-text"
>
</button>
</div>
`
: ''}
<div class="mb-6">
<div class="flex items-center justify-between mb-4">
<h3 class="font-mono text-lg text-dark-text">SSH Keys</h3>
<button
@click=${() => (this.showAddForm = !this.showAddForm)}
class="btn-primary"
?disabled=${this.loading}
>
${this.showAddForm ? 'Cancel' : 'Add Key'}
</button>
</div>
${this.showAddForm
? html`
<div class="space-y-6 mb-4">
<!-- Generate New Key Section -->
<div class="bg-dark-surface border border-dark-border rounded p-4">
<h4 class="text-dark-text font-mono text-lg mb-4 flex items-center gap-2">
🔑 Generate New SSH Key
</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div>
<label class="form-label"
>Key Name <span class="text-accent-red">*</span></label
>
<input
type="text"
class="input-field"
placeholder="Enter name for new key"
.value=${this.newKeyName}
@input=${(e: Event) =>
(this.newKeyName = (e.target as HTMLInputElement).value)}
?disabled=${this.loading}
/>
</div>
<div>
<label class="form-label">Algorithm</label>
<div
class="input-field bg-dark-bg-secondary text-dark-text-muted cursor-not-allowed"
>
Ed25519 (recommended)
</div>
</div>
</div>
<div class="mb-4">
<label class="form-label">Password (Optional)</label>
<input
type="password"
class="input-field"
placeholder="Enter password to encrypt private key (optional)"
.value=${this.newKeyPassword}
@input=${(e: Event) =>
(this.newKeyPassword = (e.target as HTMLInputElement).value)}
?disabled=${this.loading}
/>
<p class="text-dark-text-muted text-xs mt-1">
💡 Leave empty for unencrypted key. Password is required when using the
key for signing.
</p>
</div>
<button
@click=${this.handleGenerateKey}
class="btn-primary"
?disabled=${this.loading || !this.newKeyName.trim()}
>
${this.loading ? 'Generating...' : 'Generate New Key'}
</button>
</div>
<!-- Import Existing Key Section -->
<div class="bg-dark-surface border border-dark-border rounded p-4">
<h4 class="text-dark-text font-mono text-lg mb-4 flex items-center gap-2">
📁 Import Existing SSH Key
</h4>
<div class="mb-4">
<label class="form-label"
>Key Name <span class="text-accent-red">*</span></label
>
<input
type="text"
class="input-field"
placeholder="Enter name for imported key"
.value=${this.importKeyName}
@input=${(e: Event) =>
(this.importKeyName = (e.target as HTMLInputElement).value)}
?disabled=${this.loading}
/>
</div>
<div class="mb-4">
<label class="form-label"
>Private Key (PEM format) <span class="text-accent-red">*</span></label
>
<textarea
class="input-field"
rows="6"
placeholder="-----BEGIN PRIVATE KEY-----&#10;...&#10;-----END PRIVATE KEY-----"
.value=${this.importKeyContent}
@input=${(e: Event) =>
(this.importKeyContent = (e.target as HTMLTextAreaElement).value)}
?disabled=${this.loading}
></textarea>
<p class="text-dark-text-muted text-xs mt-1">
💡 If the key is password-protected, you'll be prompted for the password
when using it for authentication.
</p>
</div>
<button
@click=${this.handleImportKey}
class="btn-secondary"
?disabled=${this.loading ||
!this.importKeyName.trim() ||
!this.importKeyContent.trim()}
>
${this.loading ? 'Importing...' : 'Import Key'}
</button>
</div>
</div>
`
: ''}
</div>
<!-- Instructions for new key -->
${this.showInstructions && this.instructionsKeyId
? html`
<div class="bg-dark-surface border border-dark-border rounded p-4 mb-6">
<div class="flex items-center justify-between mb-4">
<h4 class="text-dark-text font-mono text-lg">Setup Instructions</h4>
<button
@click=${() => (this.showInstructions = false)}
class="text-dark-text-muted hover:text-dark-text"
>
</button>
</div>
<div class="space-y-4">
<div class="bg-dark-bg border border-dark-border rounded p-3">
<p class="text-dark-text-muted text-xs mb-2">
1. Add the public key to your authorized_keys file:
</p>
<div class="relative">
<pre
class="bg-dark-bg-secondary p-2 rounded text-xs overflow-x-auto text-dark-text pr-20"
>
echo "${this.sshAgent.getPublicKey(this.instructionsKeyId)}" >> ~/.ssh/authorized_keys</pre
>
<button
@click=${async () => {
const publicKey = this.sshAgent.getPublicKey(this.instructionsKeyId);
const command = `echo "${publicKey}" >> ~/.ssh/authorized_keys`;
await navigator.clipboard.writeText(command);
this.success = 'Command copied to clipboard!';
}}
class="absolute top-2 right-2 btn-ghost text-xs"
title="Copy command"
>
📋
</button>
</div>
</div>
<div class="bg-dark-bg border border-dark-border rounded p-3">
<p class="text-dark-text-muted text-xs mb-2">2. Or copy the public key:</p>
<div class="relative">
<pre
class="bg-dark-bg-secondary p-2 rounded text-xs overflow-x-auto text-dark-text pr-20"
>
${this.sshAgent.getPublicKey(this.instructionsKeyId)}</pre
>
<button
@click=${async () => {
const publicKey = this.sshAgent.getPublicKey(this.instructionsKeyId);
if (publicKey) {
await navigator.clipboard.writeText(publicKey);
this.success = 'Public key copied to clipboard!';
}
}}
class="absolute top-2 right-2 btn-ghost text-xs"
title="Copy to clipboard"
>
📋 Copy
</button>
</div>
</div>
<p class="text-dark-text-muted text-xs font-mono">
💡 Tip: Make sure ~/.ssh/authorized_keys has correct permissions (600)
</p>
</div>
</div>
`
: ''}
<!-- Keys List -->
<div class="space-y-4">
${this.keys.length === 0
? html`
<div class="text-center py-8 text-dark-text-muted">
<p class="font-mono text-lg mb-2">No SSH keys found</p>
<p class="text-sm">Generate or import a key to get started</p>
</div>
`
: this.keys.map(
(key) => html`
<div class="ssh-key-item">
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-2 mb-2">
<h4 class="font-mono font-semibold text-dark-text">${key.name}</h4>
<span class="badge badge-ed25519">${key.algorithm}</span>
${key.encrypted
? html`<span class="badge badge-encrypted">🔒 Encrypted</span>`
: ''}
</div>
<div class="text-sm text-dark-text-muted font-mono space-y-1">
<div>ID: ${key.id}</div>
<div>Fingerprint: ${key.fingerprint}</div>
<div>Created: ${new Date(key.createdAt).toLocaleString()}</div>
</div>
</div>
<div class="flex gap-2">
<button
@click=${() => this.handleDownloadPublicKey(key.id, key.name)}
class="btn-ghost text-xs"
title="Download Public Key"
>
📥 Public
</button>
<button
@click=${() => this.handleRemoveKey(key.id, key.name)}
class="btn-ghost text-xs text-status-error hover:bg-status-error hover:text-dark-bg"
title="Remove Key"
>
🗑
</button>
</div>
</div>
</div>
`
)}
</div>
</div>
</div>
`;
}
}

View file

@ -0,0 +1,385 @@
import { BrowserSSHAgent } from './ssh-agent.js';
interface AuthResponse {
success: boolean;
token?: string;
userId?: string;
authMethod?: 'ssh-key' | 'password';
error?: string;
}
interface Challenge {
challengeId: string;
challenge: string;
expiresAt: number;
}
interface User {
userId: string;
token: string;
authMethod: 'ssh-key' | 'password';
loginTime: number;
}
export class AuthClient {
private static readonly TOKEN_KEY = 'vibetunnel_auth_token';
private static readonly USER_KEY = 'vibetunnel_user_data';
private currentUser: User | null = null;
private sshAgent: BrowserSSHAgent;
constructor() {
this.sshAgent = new BrowserSSHAgent();
this.loadCurrentUser();
}
/**
* Get SSH agent instance
*/
getSSHAgent(): BrowserSSHAgent {
return this.sshAgent;
}
/**
* Check if user is authenticated
*/
isAuthenticated(): boolean {
return this.currentUser !== null && this.isTokenValid();
}
/**
* Get current user info
*/
getCurrentUser(): User | null {
return this.currentUser;
}
/**
* Get current system user from server
*/
async getCurrentSystemUser(): Promise<string> {
try {
const response = await fetch('/api/auth/current-user');
if (response.ok) {
const data = await response.json();
return data.userId;
}
throw new Error('Failed to get current user');
} catch (error) {
console.error('Failed to get current system user:', error);
throw error;
}
}
/**
* Get user avatar (macOS returns base64, others get generic)
*/
async getUserAvatar(userId: string): Promise<string> {
try {
const response = await fetch(`/api/auth/avatar/${userId}`);
if (response.ok) {
const data = await response.json();
if (data.avatar) {
// If it's a data URL (base64), return as is
if (data.avatar.startsWith('data:')) {
return data.avatar;
}
// If it's a file path, we'd need to handle that differently
// For now, fall back to generic avatar
}
}
} catch (error) {
console.error('Failed to get user avatar:', error);
}
// Return generic avatar SVG for non-macOS or when no avatar found
return (
'data:image/svg+xml;base64,' +
btoa(`
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="24" cy="24" r="24" fill="#6B7280"/>
<circle cx="24" cy="18" r="8" fill="#9CA3AF"/>
<path d="M8 38c0-8.837 7.163-16 16-16s16 7.163 16 16" fill="#9CA3AF"/>
</svg>
`)
);
}
/**
* Authenticate using SSH key (priority method)
*/
async authenticateWithSSHKey(userId: string, keyId: string): Promise<AuthResponse> {
try {
// Check if SSH agent is unlocked
if (!this.sshAgent.isUnlocked()) {
return { success: false, error: 'SSH agent is locked' };
}
// Create challenge
const challenge = await this.createChallenge(userId);
// Sign challenge with SSH key
const signatureResult = await this.sshAgent.sign(keyId, challenge.challenge);
const publicKey = this.sshAgent.getPublicKey(keyId);
if (!publicKey) {
return { success: false, error: 'SSH key not found' };
}
// Send authentication request
const response = await fetch('/api/auth/ssh-key', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
challengeId: challenge.challengeId,
publicKey,
signature: signatureResult.signature,
}),
});
const result = await response.json();
console.log('🔐 SSH key auth server response:', result);
if (result.success) {
console.log('✅ SSH key auth successful, setting current user');
this.setCurrentUser({
userId: result.userId,
token: result.token,
authMethod: 'ssh-key',
loginTime: Date.now(),
});
console.log('👤 Current user set:', this.getCurrentUser());
} else {
console.log('❌ SSH key auth failed:', result.error);
}
return result;
} catch (error) {
console.error('SSH key authentication failed:', error);
return { success: false, error: 'SSH key authentication failed' };
}
}
/**
* Authenticate using password (fallback method)
*/
async authenticateWithPassword(userId: string, password: string): Promise<AuthResponse> {
try {
const response = await fetch('/api/auth/password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId, password }),
});
const result = await response.json();
if (result.success) {
this.setCurrentUser({
userId: result.userId,
token: result.token,
authMethod: 'password',
loginTime: Date.now(),
});
}
return result;
} catch (error) {
console.error('Password authentication failed:', error);
return { success: false, error: 'Password authentication failed' };
}
}
/**
* Automated authentication - tries SSH keys first, then prompts for password
*/
async authenticate(userId: string): Promise<AuthResponse> {
console.log('🚀 Starting SSH authentication for user:', userId);
// Try SSH key authentication first if agent is unlocked
if (this.sshAgent.isUnlocked()) {
const keys = this.sshAgent.listKeys();
console.log(
'🗝️ Found SSH keys:',
keys.length,
keys.map((k) => ({ id: k.id, name: k.name }))
);
for (const key of keys) {
try {
console.log(`🔑 Trying SSH key: ${key.name} (${key.id})`);
const result = await this.authenticateWithSSHKey(userId, key.id);
console.log(`🎯 SSH key ${key.name} result:`, result);
if (result.success) {
console.log(`✅ Authenticated with SSH key: ${key.name}`);
return result;
}
} catch (error) {
console.warn(`❌ SSH key authentication failed for key ${key.name}:`, error);
continue;
}
}
} else {
console.log('🔒 SSH agent is locked');
}
// SSH key auth failed or no keys available
return {
success: false,
error: 'SSH key authentication failed. Password authentication required.',
};
}
/**
* Logout user
*/
async logout(): Promise<void> {
try {
// Call server logout endpoint
if (this.currentUser?.token) {
await fetch('/api/auth/logout', {
method: 'POST',
headers: {
Authorization: `Bearer ${this.currentUser.token}`,
'Content-Type': 'application/json',
},
});
}
} catch (error) {
console.warn('Server logout failed:', error);
} finally {
// Clear local state
this.clearCurrentUser();
}
}
/**
* Get authorization header for API requests
*/
getAuthHeader(): Record<string, string> {
if (this.currentUser?.token) {
return { Authorization: `Bearer ${this.currentUser.token}` };
}
console.warn('⚠️ No token available for auth header');
return {};
}
/**
* Verify current token with server
*/
async verifyToken(): Promise<boolean> {
if (!this.currentUser?.token) return false;
try {
const response = await fetch('/api/auth/verify', {
headers: { Authorization: `Bearer ${this.currentUser.token}` },
});
const result = await response.json();
return result.valid;
} catch (error) {
console.error('Token verification failed:', error);
return false;
}
}
/**
* Unlock SSH agent (no-op since we don't use encryption)
*/
async unlockSSHAgent(_passphrase: string): Promise<boolean> {
return true; // Always unlocked
}
/**
* Lock SSH agent (no-op since we don't use encryption)
*/
lockSSHAgent(): void {
// No-op since agent is always unlocked
}
/**
* Check if SSH agent is unlocked
*/
isSSHAgentUnlocked(): boolean {
return true; // Always unlocked since we don't use encryption
}
// Private methods
private async createChallenge(userId: string): Promise<Challenge> {
const response = await fetch('/api/auth/challenge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId }),
});
if (!response.ok) {
throw new Error('Failed to create authentication challenge');
}
return response.json();
}
private setCurrentUser(user: User): void {
this.currentUser = user;
this.saveCurrentUser();
}
private clearCurrentUser(): void {
this.currentUser = null;
localStorage.removeItem(AuthClient.TOKEN_KEY);
localStorage.removeItem(AuthClient.USER_KEY);
}
private saveCurrentUser(): void {
if (this.currentUser) {
localStorage.setItem(AuthClient.TOKEN_KEY, this.currentUser.token);
localStorage.setItem(
AuthClient.USER_KEY,
JSON.stringify({
userId: this.currentUser.userId,
authMethod: this.currentUser.authMethod,
loginTime: this.currentUser.loginTime,
})
);
}
}
private loadCurrentUser(): void {
try {
const token = localStorage.getItem(AuthClient.TOKEN_KEY);
const userData = localStorage.getItem(AuthClient.USER_KEY);
if (token && userData) {
const user = JSON.parse(userData);
this.currentUser = {
token,
userId: user.userId,
authMethod: user.authMethod,
loginTime: user.loginTime,
};
// Verify token is still valid
this.verifyToken().then((valid) => {
if (!valid) {
this.clearCurrentUser();
}
});
}
} catch (error) {
console.error('Failed to load current user:', error);
this.clearCurrentUser();
}
}
private isTokenValid(): boolean {
if (!this.currentUser) return false;
// Check if token is expired (24 hours)
const tokenAge = Date.now() - this.currentUser.loginTime;
const maxAge = 24 * 60 * 60 * 1000; // 24 hours
return tokenAge < maxAge;
}
}

View file

@ -0,0 +1,456 @@
// Use Web Crypto API available in browsers
const { subtle } = window.crypto;
interface SSHKey {
id: string;
name: string;
publicKey: string;
privateKey: string;
algorithm: 'Ed25519';
encrypted: boolean;
fingerprint: string;
createdAt: string;
}
interface SignatureResult {
signature: string;
algorithm: string;
}
export class BrowserSSHAgent {
private static readonly DEFAULT_STORAGE_KEY = 'vibetunnel_ssh_keys';
private keys: Map<string, SSHKey> = new Map();
private storageKey: string;
constructor(customStorageKey?: string) {
this.storageKey = customStorageKey || BrowserSSHAgent.DEFAULT_STORAGE_KEY;
this.loadKeysFromStorage();
}
/**
* Check if agent is ready (always true since no unlock needed)
*/
isUnlocked(): boolean {
return true;
}
/**
* Add SSH private key to the agent
*/
async addKey(name: string, privateKeyPEM: string): Promise<string> {
try {
// Parse and validate the private key (detect encryption without decrypting)
const keyData = await this.parsePrivateKey(privateKeyPEM);
const keyId = this.generateKeyId();
const sshKey: SSHKey = {
id: keyId,
name,
publicKey: keyData.publicKey,
privateKey: privateKeyPEM,
algorithm: 'Ed25519',
encrypted: keyData.encrypted,
fingerprint: keyData.fingerprint,
createdAt: new Date().toISOString(),
};
this.keys.set(keyId, sshKey);
this.saveKeysToStorage();
return keyId;
} catch (error) {
throw new Error(`Failed to add SSH key: ${error}`);
}
}
/**
* Remove SSH key from agent
*/
removeKey(keyId: string): void {
this.keys.delete(keyId);
this.saveKeysToStorage();
}
/**
* List all SSH keys
*/
listKeys(): Array<Omit<SSHKey, 'privateKey'>> {
return Array.from(this.keys.values()).map((key) => ({
id: key.id,
name: key.name,
publicKey: key.publicKey,
algorithm: key.algorithm,
encrypted: key.encrypted,
fingerprint: key.fingerprint,
createdAt: key.createdAt,
}));
}
/**
* Sign data with a specific SSH key
*/
async sign(keyId: string, data: string): Promise<SignatureResult> {
const key = this.keys.get(keyId);
if (!key) {
throw new Error('SSH key not found');
}
if (!key.privateKey) {
throw new Error('Private key not available for signing');
}
try {
// Decrypt private key if encrypted
let privateKeyPEM = key.privateKey;
if (key.encrypted) {
// Prompt for password if key is encrypted
const password = await this.promptForPassword(key.name);
if (!password) {
throw new Error('Password required for encrypted key');
}
privateKeyPEM = await this.decryptPrivateKey(key.privateKey, password);
}
// Import the private key for signing
const privateKey = await this.importPrivateKey(privateKeyPEM, key.algorithm);
// Convert challenge data to buffer (browser-compatible)
const dataBuffer = this.base64ToArrayBuffer(data);
// Sign the data
const signature = await subtle.sign({ name: 'Ed25519' }, privateKey, dataBuffer);
// Return base64 encoded signature
return {
signature: this.arrayBufferToBase64(signature),
algorithm: key.algorithm,
};
} catch (error) {
throw new Error(`Failed to sign data: ${error}`);
}
}
/**
* Generate SSH key pair in the browser
*/
async generateKeyPair(
name: string,
password?: string
): Promise<{ keyId: string; privateKeyPEM: string }> {
console.log(`🔑 SSH Agent: Starting Ed25519 key generation for "${name}"`);
try {
const keyPair = await subtle.generateKey(
{
name: 'Ed25519',
} as AlgorithmIdentifier,
true,
['sign', 'verify']
);
// Export keys
const cryptoKeyPair = keyPair as CryptoKeyPair;
const privateKeyBuffer = await subtle.exportKey('pkcs8', cryptoKeyPair.privateKey);
const publicKeyBuffer = await subtle.exportKey('raw', cryptoKeyPair.publicKey);
// Convert to proper formats
let privateKeyPEM = this.arrayBufferToPEM(privateKeyBuffer, 'PRIVATE KEY');
const publicKeySSH = this.convertEd25519ToSSHPublicKey(publicKeyBuffer);
// Encrypt private key if password provided
const isEncrypted = !!password;
if (password) {
privateKeyPEM = await this.encryptPrivateKey(privateKeyPEM, password);
}
const keyId = this.generateKeyId();
const sshKey: SSHKey = {
id: keyId,
name,
publicKey: publicKeySSH,
privateKey: privateKeyPEM,
algorithm: 'Ed25519',
encrypted: isEncrypted,
fingerprint: await this.generateFingerprint(publicKeySSH),
createdAt: new Date().toISOString(),
};
// Store key with private key for browser-based signing
this.keys.set(keyId, sshKey);
await this.saveKeysToStorage();
console.log(`🔑 SSH Agent: Key "${name}" generated successfully with ID: ${keyId}`);
return { keyId, privateKeyPEM };
} catch (error) {
throw new Error(`Failed to generate key pair: ${error}`);
}
}
/**
* Export public key in SSH format
*/
getPublicKey(keyId: string): string | null {
const key = this.keys.get(keyId);
return key ? key.publicKey : null;
}
/**
* Get private key for a specific key ID
*/
getPrivateKey(keyId: string): string | null {
const key = this.keys.get(keyId);
return key ? key.privateKey : null;
}
// Private helper methods
private async parsePrivateKey(privateKeyPEM: string): Promise<{
publicKey: string;
algorithm: 'Ed25519';
fingerprint: string;
encrypted: boolean;
}> {
// Check if key is encrypted
const isEncrypted =
privateKeyPEM.includes('BEGIN ENCRYPTED PRIVATE KEY') ||
privateKeyPEM.includes('Proc-Type: 4,ENCRYPTED');
// Only support Ed25519 keys
if (
privateKeyPEM.includes('BEGIN PRIVATE KEY') ||
privateKeyPEM.includes('BEGIN ENCRYPTED PRIVATE KEY')
) {
// For imported keys, we need to extract the public key
// This is a simplified implementation - in production use proper key parsing
const mockPublicKey = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIImported...';
return {
publicKey: mockPublicKey,
algorithm: 'Ed25519',
fingerprint: await this.generateFingerprint(mockPublicKey),
encrypted: isEncrypted,
};
}
throw new Error('Only Ed25519 private keys are supported');
}
private async importPrivateKey(privateKeyPEM: string, _algorithm: 'Ed25519'): Promise<CryptoKey> {
// Remove PEM headers and decode
const pemContents = privateKeyPEM
.replace('-----BEGIN PRIVATE KEY-----', '')
.replace('-----END PRIVATE KEY-----', '')
.replace(/\s/g, '');
const keyData = this.base64ToArrayBuffer(pemContents);
return subtle.importKey(
'pkcs8',
keyData,
{
name: 'Ed25519',
},
false,
['sign']
);
}
private convertEd25519ToSSHPublicKey(publicKeyBuffer: ArrayBuffer): string {
// Convert raw Ed25519 public key to SSH format
const publicKeyBytes = new Uint8Array(publicKeyBuffer);
// SSH Ed25519 public key format:
// string "ssh-ed25519" + string (32-byte public key)
const keyType = 'ssh-ed25519';
const keyTypeBytes = new TextEncoder().encode(keyType);
// Build the SSH wire format
const buffer = new ArrayBuffer(4 + keyTypeBytes.length + 4 + publicKeyBytes.length);
const view = new DataView(buffer);
const bytes = new Uint8Array(buffer);
let offset = 0;
// Write key type length and key type
view.setUint32(offset, keyTypeBytes.length, false);
offset += 4;
bytes.set(keyTypeBytes, offset);
offset += keyTypeBytes.length;
// Write public key length and public key
view.setUint32(offset, publicKeyBytes.length, false);
offset += 4;
bytes.set(publicKeyBytes, offset);
// Base64 encode the result
const base64Key = this.arrayBufferToBase64(buffer);
return `ssh-ed25519 ${base64Key}`;
}
private async generateFingerprint(publicKey: string): Promise<string> {
const encoder = new TextEncoder();
const hash = await subtle.digest('SHA-256', encoder.encode(publicKey));
return this.arrayBufferToBase64(hash).substring(0, 16);
}
private generateKeyId(): string {
return window.crypto.randomUUID();
}
private arrayBufferToBase64(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
private base64ToArrayBuffer(base64: string): ArrayBuffer {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer;
}
private arrayBufferToPEM(buffer: ArrayBuffer, type: string): string {
const base64 = this.arrayBufferToBase64(buffer);
const lines = base64.match(/.{1,64}/g) || [];
return `-----BEGIN ${type}-----\n${lines.join('\n')}\n-----END ${type}-----`;
}
private async loadKeysFromStorage(): Promise<void> {
try {
const keysData = localStorage.getItem(this.storageKey);
if (keysData) {
// Load directly without decryption
const keys: SSHKey[] = JSON.parse(keysData);
this.keys.clear();
keys.forEach((key) => this.keys.set(key.id, key));
}
} catch (error) {
console.error('Failed to load SSH keys from storage:', error);
}
}
private async saveKeysToStorage(): Promise<void> {
try {
const keysArray = Array.from(this.keys.values());
// Store directly without encryption
localStorage.setItem(this.storageKey, JSON.stringify(keysArray));
} catch (error) {
console.error('Failed to save SSH keys to storage:', error);
}
}
/**
* Encrypt private key with password using Web Crypto API
*/
private async encryptPrivateKey(privateKeyPEM: string, password: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(privateKeyPEM);
// Derive key from password using PBKDF2
const passwordKey = await subtle.importKey(
'raw',
encoder.encode(password),
{ name: 'PBKDF2' },
false,
['deriveKey']
);
// Generate random salt
const salt = crypto.getRandomValues(new Uint8Array(16));
// Derive encryption key
const encryptionKey = await subtle.deriveKey(
{
name: 'PBKDF2',
salt,
iterations: 100000,
hash: 'SHA-256',
},
passwordKey,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt']
);
// Generate random IV
const iv = crypto.getRandomValues(new Uint8Array(12));
// Encrypt the data
const encryptedData = await subtle.encrypt({ name: 'AES-GCM', iv }, encryptionKey, data);
// Combine salt + iv + encrypted data and base64 encode
const combined = new Uint8Array(salt.length + iv.length + encryptedData.byteLength);
combined.set(salt, 0);
combined.set(iv, salt.length);
combined.set(new Uint8Array(encryptedData), salt.length + iv.length);
return `-----BEGIN ENCRYPTED PRIVATE KEY-----\n${this.arrayBufferToBase64(combined.buffer)}\n-----END ENCRYPTED PRIVATE KEY-----`;
}
/**
* Decrypt private key with password
*/
private async decryptPrivateKey(
encryptedPrivateKeyPEM: string,
password: string
): Promise<string> {
// Extract base64 data
const base64Data = encryptedPrivateKeyPEM
.replace('-----BEGIN ENCRYPTED PRIVATE KEY-----', '')
.replace('-----END ENCRYPTED PRIVATE KEY-----', '')
.replace(/\s/g, '');
const combinedData = this.base64ToArrayBuffer(base64Data);
const combined = new Uint8Array(combinedData);
// Extract salt, iv, and encrypted data
const salt = combined.slice(0, 16);
const iv = combined.slice(16, 28);
const encryptedData = combined.slice(28);
const encoder = new TextEncoder();
// Derive key from password
const passwordKey = await subtle.importKey(
'raw',
encoder.encode(password),
{ name: 'PBKDF2' },
false,
['deriveKey']
);
const encryptionKey = await subtle.deriveKey(
{
name: 'PBKDF2',
salt,
iterations: 100000,
hash: 'SHA-256',
},
passwordKey,
{ name: 'AES-GCM', length: 256 },
false,
['decrypt']
);
// Decrypt the data
const decryptedData = await subtle.decrypt(
{ name: 'AES-GCM', iv },
encryptionKey,
encryptedData
);
const decoder = new TextDecoder();
return decoder.decode(decryptedData);
}
/**
* Prompt user for password using browser dialog
*/
private async promptForPassword(keyName: string): Promise<string | null> {
return window.prompt(`Enter password for SSH key "${keyName}":`);
}
}

View file

@ -126,6 +126,60 @@
grid-auto-rows: 300px;
}
}
/* Authentication styles */
.auth-container {
@apply min-h-screen bg-dark-bg flex items-center justify-center p-6;
}
.auth-header {
@apply text-center mb-8;
}
.auth-title {
@apply text-3xl font-mono font-bold text-dark-text mb-2;
}
.auth-subtitle {
@apply text-dark-text-muted font-mono;
}
.auth-form {
@apply bg-dark-bg-secondary border border-dark-border rounded-lg p-6 w-full space-y-6;
}
.auth-divider {
@apply relative text-center text-dark-text-muted font-mono text-sm;
}
.auth-divider::before {
@apply absolute top-1/2 left-0 w-full h-px bg-dark-border;
content: '';
transform: translateY(-50%);
}
.auth-divider span {
@apply bg-dark-bg-secondary px-4;
}
/* SSH Key Manager styles */
.ssh-key-item {
@apply bg-dark-bg border border-dark-border rounded-lg p-4;
@apply transition-all duration-200 ease-in-out;
@apply hover:border-accent-green-darker;
}
.badge {
@apply px-2 py-1 rounded text-xs font-mono font-semibold;
}
.badge-rsa {
@apply bg-blue-500 text-white;
}
.badge-ed25519 {
@apply bg-purple-500 text-white;
}
}
/* Fira Code Variable Font */

View file

@ -0,0 +1,7 @@
declare module 'authenticate-pam' {
export function authenticate(
username: string,
password: string,
callback: (err: Error | null) => void
): void;
}

View file

@ -45,10 +45,15 @@ function formatArgs(args: unknown[]): unknown[] {
*/
async function sendToServer(level: keyof LogLevel, module: string, args: unknown[]): Promise<void> {
try {
// Import AuthClient dynamically to avoid circular dependencies
const { AuthClient } = await import('../services/auth-client.js');
const authClient = new AuthClient();
await fetch('/api/logs/client', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...authClient.getAuthHeader(),
},
body: JSON.stringify({
level,

View file

@ -1,80 +1,110 @@
import { Request, Response, NextFunction } from 'express';
import chalk from 'chalk';
import { createLogger } from '../utils/logger.js';
const logger = createLogger('auth');
import { AuthService } from '../services/auth-service.js';
interface AuthConfig {
basicAuthUsername: string | null;
basicAuthPassword: string | null;
enableSSHKeys: boolean;
disallowUserPassword: boolean;
noAuth: boolean;
isHQMode: boolean;
bearerToken?: string; // Token that HQ must use to authenticate with this remote
authService?: AuthService; // Enhanced auth service for JWT tokens
}
interface AuthenticatedRequest extends Request {
userId?: string;
authMethod?: 'ssh-key' | 'password' | 'hq-bearer' | 'no-auth';
isHQRequest?: boolean;
}
export function createAuthMiddleware(config: AuthConfig) {
return (req: Request, res: Response, next: NextFunction) => {
// Skip auth for health check endpoint
if (req.path === '/api/health') {
logger.debug('bypassing auth for health check endpoint');
return (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
// Skip auth for health check endpoint, auth endpoints, client logging, and push notifications
if (
req.path === '/api/health' ||
req.path.startsWith('/api/auth') ||
req.path.startsWith('/api/logs') ||
req.path.startsWith('/api/push')
) {
return next();
}
// If no auth configured, allow all requests
if (!config.basicAuthUsername || !config.basicAuthPassword) {
logger.debug('no auth configured, allowing request');
// If no auth is disabled, allow all requests
if (config.noAuth) {
req.authMethod = 'no-auth';
return next();
}
logger.debug(`auth check for ${req.method} ${req.path} from ${req.ip}`);
// Only log auth requests that might be problematic (no header or failures)
// Remove verbose logging for successful token auth to reduce spam
// Check for Bearer token (for HQ to remote communication)
const authHeader = req.headers.authorization;
const tokenQuery = req.query.token as string;
// Check for Bearer token
if (authHeader && authHeader.startsWith('Bearer ')) {
const token = authHeader.substring(7);
// In HQ mode, bearer tokens are not accepted (HQ uses basic auth)
if (config.isHQMode) {
logger.warn(`bearer token rejected in HQ mode from ${req.ip}`);
res.setHeader('WWW-Authenticate', 'Basic realm="VibeTunnel"');
return res.status(401).json({ error: 'Bearer token not accepted in HQ mode' });
} else if (config.bearerToken && token === config.bearerToken) {
// Token matches what this remote server expects from HQ
logger.log(chalk.green(`authenticated via bearer token from ${req.ip}`));
// In HQ mode, check if this is a valid HQ-to-remote bearer token
if (config.isHQMode && config.bearerToken && token === config.bearerToken) {
console.log('[AUTH] ✅ Valid HQ bearer token authentication');
req.isHQRequest = true;
req.authMethod = 'hq-bearer';
return next();
} else if (config.bearerToken) {
// We have a bearer token configured but it doesn't match
logger.warn(`invalid bearer token from ${req.ip}`);
}
// If we have enhanced auth service and SSH keys are enabled, try JWT token validation
if (config.authService && config.enableSSHKeys) {
const verification = config.authService.verifyToken(token);
if (verification.valid && verification.userId) {
req.userId = verification.userId;
req.authMethod = 'ssh-key'; // JWT tokens are issued for SSH key auth
return next();
} else {
console.log('[AUTH] ❌ Invalid JWT token');
}
} else if (config.authService) {
const verification = config.authService.verifyToken(token);
if (verification.valid && verification.userId) {
console.log(`[AUTH] ✅ Valid JWT token for user: ${verification.userId}`);
req.userId = verification.userId;
req.authMethod = 'password'; // Password auth only
return next();
} else {
console.log('[AUTH] ❌ Invalid JWT token');
}
}
// For non-HQ mode, check if bearer token matches remote expectation
if (!config.isHQMode && config.bearerToken && token === config.bearerToken) {
console.log('[AUTH] ✅ Valid remote bearer token authentication');
req.authMethod = 'hq-bearer';
return next();
}
console.log(
`[AUTH] ❌ Bearer token rejected - HQ mode: ${config.isHQMode}, token matches: ${config.bearerToken === token}`
);
}
// Check Basic auth
if (authHeader && authHeader.startsWith('Basic ')) {
const base64Credentials = authHeader.substring(6);
const credentials = Buffer.from(base64Credentials, 'base64').toString('utf8');
const [username, password] = credentials.split(':');
// If no username is configured, accept any username as long as password matches
// This allows for password-only authentication mode
if (!config.basicAuthUsername) {
// Password-only mode: ignore username, only check password
if (password === config.basicAuthPassword) {
logger.log(chalk.green(`authenticated via password-only mode from ${req.ip}`));
return next();
} else {
logger.warn(`failed password-only auth attempt from ${req.ip}`);
}
// Check for token in query parameter (for EventSource connections)
if (tokenQuery && config.authService) {
const verification = config.authService.verifyToken(tokenQuery);
if (verification.valid && verification.userId) {
console.log(`[AUTH] ✅ Valid query token for user: ${verification.userId}`);
req.userId = verification.userId;
req.authMethod = config.enableSSHKeys ? 'ssh-key' : 'password';
return next();
} else {
// Username+password mode: check both
if (username === config.basicAuthUsername && password === config.basicAuthPassword) {
return next();
} else {
logger.warn(`failed basic auth attempt from ${req.ip} for user: ${username}`);
}
console.log('[AUTH] ❌ Invalid query token');
}
}
// No valid auth provided
logger.warn(`unauthorized request to ${req.method} ${req.path} from ${req.ip}`);
res.setHeader('WWW-Authenticate', 'Basic realm="VibeTunnel"');
console.log(
chalk.red(`[AUTH] ❌ Unauthorized request to ${req.method} ${req.path} from ${req.ip}`)
);
res.setHeader('WWW-Authenticate', 'Bearer realm="VibeTunnel"');
res.status(401).json({ error: 'Authentication required' });
};
}

View file

@ -0,0 +1,267 @@
import { Router } from 'express';
import { AuthService } from '../services/auth-service.js';
import { exec } from 'child_process';
import { promisify } from 'util';
const execAsync = promisify(exec);
interface AuthRoutesConfig {
authService: AuthService;
enableSSHKeys?: boolean;
disallowUserPassword?: boolean;
noAuth?: boolean;
}
export function createAuthRoutes(config: AuthRoutesConfig): Router {
const router = Router();
const { authService } = config;
/**
* Create authentication challenge for SSH key auth
* POST /api/auth/challenge
*/
router.post('/challenge', async (req, res) => {
try {
const { userId } = req.body;
if (!userId) {
return res.status(400).json({ error: 'User ID is required' });
}
// Check if user exists
const userExists = await authService.userExists(userId);
if (!userExists) {
return res.status(404).json({ error: 'User not found' });
}
// Create challenge
const challenge = authService.createChallenge(userId);
res.json({
challengeId: challenge.challengeId,
challenge: challenge.challenge,
expiresAt: Date.now() + 5 * 60 * 1000, // 5 minutes
});
} catch (error) {
console.error('Error creating auth challenge:', error);
res.status(500).json({ error: 'Failed to create authentication challenge' });
}
});
/**
* Authenticate with SSH key
* POST /api/auth/ssh-key
*/
router.post('/ssh-key', async (req, res) => {
try {
const { challengeId, publicKey, signature } = req.body;
if (!challengeId || !publicKey || !signature) {
return res.status(400).json({
error: 'Challenge ID, public key, and signature are required',
});
}
const result = await authService.authenticateWithSSHKey({
challengeId,
publicKey,
signature,
});
if (result.success) {
res.json({
success: true,
token: result.token,
userId: result.userId,
authMethod: 'ssh-key',
});
} else {
res.status(401).json({
success: false,
error: result.error,
});
}
} catch (error) {
console.error('Error authenticating with SSH key:', error);
res.status(500).json({ error: 'SSH key authentication failed' });
}
});
/**
* Authenticate with password
* POST /api/auth/password
*/
router.post('/password', async (req, res) => {
try {
const { userId, password } = req.body;
if (!userId || !password) {
return res.status(400).json({
error: 'User ID and password are required',
});
}
const result = await authService.authenticateWithPassword(userId, password);
if (result.success) {
res.json({
success: true,
token: result.token,
userId: result.userId,
authMethod: 'password',
});
} else {
res.status(401).json({
success: false,
error: result.error,
});
}
} catch (error) {
console.error('Error authenticating with password:', error);
res.status(500).json({ error: 'Password authentication failed' });
}
});
/**
* Verify current authentication status
* GET /api/auth/verify
*/
router.get('/verify', (req, res) => {
try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ valid: false, error: 'No token provided' });
}
const token = authHeader.slice(7);
const verification = authService.verifyToken(token);
if (verification.valid) {
res.json({
valid: true,
userId: verification.userId,
});
} else {
res.status(401).json({
valid: false,
error: 'Invalid or expired token',
});
}
} catch (error) {
console.error('Error verifying token:', error);
res.status(500).json({ error: 'Token verification failed' });
}
});
/**
* Get current system user (for initial auth)
* GET /api/auth/current-user
*/
router.get('/current-user', (req, res) => {
try {
const currentUser = authService.getCurrentUser();
res.json({ userId: currentUser });
} catch (error) {
console.error('Error getting current user:', error);
res.status(500).json({ error: 'Failed to get current user' });
}
});
/**
* Get authentication configuration
* GET /api/auth/config
*/
router.get('/config', (req, res) => {
try {
res.json({
enableSSHKeys: config.enableSSHKeys || false,
disallowUserPassword: config.disallowUserPassword || false,
noAuth: config.noAuth || false,
});
} catch (error) {
console.error('Error getting auth config:', error);
res.status(500).json({ error: 'Failed to get auth config' });
}
});
/**
* Get user avatar (macOS only)
* GET /api/auth/avatar/:userId
*/
router.get('/avatar/:userId', async (req, res) => {
try {
const { userId } = req.params;
// Check if we're on macOS
if (process.platform !== 'darwin') {
return res.json({ avatar: null, platform: process.platform });
}
// Try to get user's JPEGPhoto from Directory Services
try {
const { stdout } = await execAsync(`dscl . -read /Users/${userId} JPEGPhoto`);
// Check if JPEGPhoto exists and extract the hex data
if (stdout.includes('JPEGPhoto:')) {
const lines = stdout.split('\n');
const hexLines = lines
.slice(1)
.filter((line) => line.trim() && !line.startsWith('dsAttrTypeNative'));
if (hexLines.length > 0) {
// Join all hex lines and remove spaces
const hexData = hexLines.join('').replace(/\s/g, '');
// Convert hex to base64
const buffer = Buffer.from(hexData, 'hex');
const base64 = buffer.toString('base64');
return res.json({
avatar: `data:image/jpeg;base64,${base64}`,
platform: 'darwin',
source: 'dscl',
});
}
}
} catch (_dsclError) {
console.log('No JPEGPhoto found for user, trying Picture attribute');
}
// Fallback: try Picture attribute (file path)
try {
const { stdout } = await execAsync(`dscl . -read /Users/${userId} Picture`);
if (stdout.includes('Picture:')) {
const picturePath = stdout.split('Picture:')[1].trim();
if (picturePath && picturePath !== 'Picture:') {
return res.json({
avatar: picturePath,
platform: 'darwin',
source: 'picture_path',
});
}
}
} catch (_pictureError) {
console.log('No Picture attribute found for user');
}
// No avatar found
res.json({ avatar: null, platform: 'darwin' });
} catch (error) {
console.error('Error getting user avatar:', error);
res.status(500).json({ error: 'Failed to get user avatar' });
}
});
/**
* Logout (invalidate token - client-side only for now)
* POST /api/auth/logout
*/
router.post('/logout', (req, res) => {
// For JWT tokens, logout is primarily client-side (remove token)
// In the future, we could implement token blacklisting
res.json({ success: true, message: 'Logged out successfully' });
});
return router;
}

View file

@ -16,6 +16,8 @@ import { createRemoteRoutes } from './routes/remotes.js';
import { createFilesystemRoutes } from './routes/filesystem.js';
import { createLogRoutes } from './routes/logs.js';
import { createPushRoutes } from './routes/push.js';
import { createAuthRoutes } from './routes/auth.js';
import { AuthService } from './services/auth-service.js';
import { ControlDirWatcher } from './services/control-dir-watcher.js';
import { VapidManager } from './utils/vapid-manager.js';
import { PushNotificationService } from './services/push-notification-service.js';
@ -42,8 +44,9 @@ export function setShuttingDown(value: boolean): void {
interface Config {
port: number | null;
bind: string | null;
basicAuthUsername: string | null;
basicAuthPassword: string | null;
enableSSHKeys: boolean;
disallowUserPassword: boolean;
noAuth: boolean;
isHQMode: boolean;
hqUrl: string | null;
hqUsername: string | null;
@ -72,8 +75,9 @@ Options:
--version Show version information
--port <number> Server port (default: 4020 or PORT env var)
--bind <address> Bind address (default: 0.0.0.0, all interfaces)
--username <string> Basic auth username (or VIBETUNNEL_USERNAME env var)
--password <string> Basic auth password (or VIBETUNNEL_PASSWORD env var)
--enable-ssh-keys Enable SSH key authentication UI and functionality
--disallow-user-password Disable password auth, SSH keys only (auto-enables --enable-ssh-keys)
--no-auth Disable authentication (auto-login as current user)
--debug Enable debug logging
Push Notification Options:
@ -120,8 +124,9 @@ function parseArgs(): Config {
const config = {
port: null as number | null,
bind: null as string | null,
basicAuthUsername: null as string | null,
basicAuthPassword: null as string | null,
enableSSHKeys: false,
disallowUserPassword: false,
noAuth: false,
isHQMode: false,
hqUrl: null as string | null,
hqUsername: null as string | null,
@ -158,12 +163,13 @@ function parseArgs(): Config {
} else if (args[i] === '--bind' && i + 1 < args.length) {
config.bind = args[i + 1];
i++; // Skip the bind value in next iteration
} else if (args[i] === '--username' && i + 1 < args.length) {
config.basicAuthUsername = args[i + 1];
i++; // Skip the username value in next iteration
} else if (args[i] === '--password' && i + 1 < args.length) {
config.basicAuthPassword = args[i + 1];
i++; // Skip the password value in next iteration
} else if (args[i] === '--enable-ssh-keys') {
config.enableSSHKeys = true;
} else if (args[i] === '--disallow-user-password') {
config.disallowUserPassword = true;
config.enableSSHKeys = true; // Auto-enable SSH keys
} else if (args[i] === '--no-auth') {
config.noAuth = true;
} else if (args[i] === '--hq') {
config.isHQMode = true;
} else if (args[i] === '--hq-url' && i + 1 < args.length) {
@ -199,14 +205,6 @@ function parseArgs(): Config {
}
}
// Check environment variables for local auth
if (!config.basicAuthUsername && process.env.VIBETUNNEL_USERNAME) {
config.basicAuthUsername = process.env.VIBETUNNEL_USERNAME;
}
if (!config.basicAuthPassword && process.env.VIBETUNNEL_PASSWORD) {
config.basicAuthPassword = process.env.VIBETUNNEL_PASSWORD;
}
// Check environment variables for push notifications
if (!config.vapidEmail && process.env.PUSH_CONTACT_EMAIL) {
config.vapidEmail = process.env.PUSH_CONTACT_EMAIL;
@ -217,11 +215,16 @@ function parseArgs(): Config {
// Validate configuration
function validateConfig(config: ReturnType<typeof parseArgs>) {
// Validate local auth configuration
if (config.basicAuthUsername && !config.basicAuthPassword) {
logger.error('Password must be provided when username is specified');
logger.error('Use --username and --password together');
process.exit(1);
// Validate auth configuration
if (config.noAuth && (config.enableSSHKeys || config.disallowUserPassword)) {
logger.warn(
'--no-auth overrides all other authentication settings (authentication is disabled)'
);
}
if (config.disallowUserPassword && !config.enableSSHKeys) {
logger.warn('--disallow-user-password requires SSH keys, auto-enabling --enable-ssh-keys');
config.enableSSHKeys = true;
}
// Validate HQ registration configuration
@ -260,15 +263,6 @@ function validateConfig(config: ReturnType<typeof parseArgs>) {
logger.error('Use --hq to run as HQ server, or --hq-url to register with an HQ');
process.exit(1);
}
// If not HQ mode and no HQ URL, warn about authentication
if (!config.basicAuthPassword && !config.isHQMode && !config.hqUrl) {
logger.warn('No authentication configured');
logger.warn('Set VIBETUNNEL_PASSWORD or use --password flag for password-only authentication');
logger.warn(
'Or use --username and --password flags together for username+password authentication'
);
}
}
interface AppInstance {
@ -422,18 +416,20 @@ export async function createApp(): Promise<AppInstance> {
});
logger.debug('Initialized buffer aggregator');
// Initialize authentication service
const authService = new AuthService();
logger.debug('Initialized authentication service');
// Set up authentication
const authMiddleware = createAuthMiddleware({
basicAuthUsername: config.basicAuthUsername,
basicAuthPassword: config.basicAuthPassword,
enableSSHKeys: config.enableSSHKeys,
disallowUserPassword: config.disallowUserPassword,
noAuth: config.noAuth,
isHQMode: config.isHQMode,
bearerToken: remoteBearerToken || undefined, // Token that HQ must use to auth with us
authService, // Add enhanced auth service for JWT tokens
});
// Apply auth middleware to all API routes
app.use('/api', authMiddleware);
logger.debug('Applied authentication middleware to /api routes');
// Serve static files with .html extension handling
const publicPath = path.join(process.cwd(), 'public');
app.use(
@ -467,6 +463,22 @@ export async function createApp(): Promise<AppInstance> {
logger.debug('Connected bell event handler to PTY manager');
}
// Mount authentication routes (no auth required)
app.use(
'/api/auth',
createAuthRoutes({
authService,
enableSSHKeys: config.enableSSHKeys,
disallowUserPassword: config.disallowUserPassword,
noAuth: config.noAuth,
})
);
logger.debug('Mounted authentication routes');
// Apply auth middleware to all API routes (except auth routes which are handled above)
app.use('/api', authMiddleware);
logger.debug('Applied authentication middleware to /api routes');
// Mount routes
app.use(
'/api',
@ -572,21 +584,21 @@ export async function createApp(): Promise<AppInstance> {
chalk.green(`VibeTunnel Server running on http://${displayAddress}:${actualPort}`)
);
if (config.basicAuthPassword) {
if (config.basicAuthUsername) {
logger.log(chalk.green('Authentication: USERNAME + PASSWORD'));
logger.log(`Username: ${config.basicAuthUsername}`);
logger.log(`Password: ${'*'.repeat(config.basicAuthPassword.length)}`);
} else {
logger.log(chalk.green('Authentication: PASSWORD ONLY'));
logger.log(`Password: ${'*'.repeat(config.basicAuthPassword.length)}`);
logger.log(chalk.gray('(Any username will be accepted)'));
}
if (config.noAuth) {
logger.warn(chalk.yellow('Authentication: DISABLED (--no-auth)'));
logger.warn('Anyone can access this server without authentication');
} else if (config.disallowUserPassword) {
logger.log(chalk.green('Authentication: SSH KEYS ONLY (--disallow-user-password)'));
logger.log(chalk.gray('Password authentication is disabled'));
} else {
logger.warn('Server running without authentication');
logger.warn(
'Anyone can access this server. Use --password for password-only auth or --username and --password for full auth'
);
logger.log(chalk.green('Authentication: SYSTEM USER PASSWORD'));
if (config.enableSSHKeys) {
logger.log(chalk.green('SSH Key Authentication: ENABLED'));
} else {
logger.log(
chalk.gray('SSH Key Authentication: DISABLED (use --enable-ssh-keys to enable)')
);
}
}
// Initialize HQ client now that we know the actual port

View file

@ -0,0 +1,302 @@
import * as pam from 'authenticate-pam';
import * as crypto from 'crypto';
import * as jwt from 'jsonwebtoken';
interface AuthChallenge {
challengeId: string;
challenge: Buffer;
timestamp: number;
userId: string;
}
interface AuthResult {
success: boolean;
userId?: string;
token?: string;
error?: string;
}
interface SSHKeyAuth {
publicKey: string;
signature: string;
challengeId: string;
}
export class AuthService {
private challenges = new Map<string, AuthChallenge>();
private jwtSecret: string;
private challengeTimeout = 5 * 60 * 1000; // 5 minutes
constructor() {
// Generate or load JWT secret
this.jwtSecret = process.env.JWT_SECRET || this.generateSecret();
// Clean up expired challenges every minute
setInterval(() => this.cleanupExpiredChallenges(), 60000);
}
private generateSecret(): string {
return crypto.randomBytes(64).toString('hex');
}
private cleanupExpiredChallenges(): void {
const now = Date.now();
for (const [id, challenge] of this.challenges.entries()) {
if (now - challenge.timestamp > this.challengeTimeout) {
this.challenges.delete(id);
}
}
}
/**
* Authenticate user with SSH key (priority method)
*/
async authenticateWithSSHKey(sshKeyAuth: SSHKeyAuth): Promise<AuthResult> {
try {
const challenge = this.challenges.get(sshKeyAuth.challengeId);
if (!challenge) {
return { success: false, error: 'Invalid or expired challenge' };
}
// Verify the signature using the original public key string
const signatureBuffer = Buffer.from(sshKeyAuth.signature, 'base64');
const isValidSignature = this.verifySSHSignature(
challenge.challenge,
signatureBuffer,
sshKeyAuth.publicKey
);
if (!isValidSignature) {
return { success: false, error: 'Invalid SSH key signature' };
}
// Check if this key is authorized for the user
const isAuthorized = await this.checkSSHKeyAuthorization(
challenge.userId,
sshKeyAuth.publicKey
);
if (!isAuthorized) {
return { success: false, error: 'SSH key not authorized for this user' };
}
// Clean up challenge
this.challenges.delete(sshKeyAuth.challengeId);
// Generate JWT token
const token = this.generateToken(challenge.userId);
return {
success: true,
userId: challenge.userId,
token,
};
} catch (error) {
console.error('SSH key authentication error:', error);
return { success: false, error: 'SSH key authentication failed' };
}
}
/**
* Authenticate user with PAM (fallback method)
*/
async authenticateWithPassword(userId: string, password: string): Promise<AuthResult> {
try {
const isValid = await this.verifyPAMCredentials(userId, password);
if (!isValid) {
return { success: false, error: 'Invalid username or password' };
}
const token = this.generateToken(userId);
return {
success: true,
userId,
token,
};
} catch (error) {
console.error('PAM authentication error:', error);
return { success: false, error: 'Authentication failed' };
}
}
/**
* Create authentication challenge for SSH key auth
*/
createChallenge(userId: string): { challengeId: string; challenge: string } {
const challengeId = crypto.randomUUID();
const challenge = crypto.randomBytes(32);
this.challenges.set(challengeId, {
challengeId,
challenge,
timestamp: Date.now(),
userId,
});
return {
challengeId,
challenge: challenge.toString('base64'),
};
}
/**
* Verify JWT token
*/
verifyToken(token: string): { valid: boolean; userId?: string } {
try {
const payload = jwt.verify(token, this.jwtSecret) as jwt.JwtPayload & { userId: string };
return { valid: true, userId: payload.userId };
} catch (_error) {
return { valid: false };
}
}
/**
* Generate JWT token
*/
private generateToken(userId: string): string {
return jwt.sign({ userId, iat: Math.floor(Date.now() / 1000) }, this.jwtSecret, {
expiresIn: '24h',
});
}
/**
* Verify credentials using PAM
*/
private async verifyPAMCredentials(username: string, password: string): Promise<boolean> {
return new Promise((resolve) => {
pam.authenticate(username, password, (err: Error | null) => {
if (err) {
console.error('PAM authentication failed:', err.message);
resolve(false);
} else {
resolve(true);
}
});
});
}
/**
* Verify SSH signature
*/
private verifySSHSignature(challenge: Buffer, signature: Buffer, publicKeyStr: string): boolean {
try {
// Basic sanity checks
if (!challenge || !signature || !publicKeyStr) {
console.error('Missing required parameters for signature verification');
return false;
}
const keyParts = publicKeyStr.trim().split(' ');
if (keyParts.length < 2) {
console.error('Invalid SSH public key format');
return false;
}
const keyType = keyParts[0];
const keyData = keyParts[1];
if (keyType === 'ssh-ed25519') {
// Check signature length
if (signature.length !== 64) {
console.error(`Invalid Ed25519 signature length: ${signature.length} (expected 64)`);
return false;
}
// Decode the SSH public key
const sshKeyBuffer = Buffer.from(keyData, 'base64');
// Parse SSH wire format: length + "ssh-ed25519" + length + 32-byte key
let offset = 0;
// Skip algorithm name length and value
const algLength = sshKeyBuffer.readUInt32BE(offset);
offset += 4 + algLength;
// Read public key length and value
const keyLength = sshKeyBuffer.readUInt32BE(offset);
offset += 4;
if (keyLength !== 32) {
console.error(`Invalid Ed25519 key length: ${keyLength} (expected 32)`);
return false;
}
const rawPublicKey = sshKeyBuffer.subarray(offset, offset + 32);
// Create a Node.js public key object
const publicKey = crypto.createPublicKey({
key: Buffer.concat([
Buffer.from([0x30, 0x2a]), // DER sequence header
Buffer.from([0x30, 0x05]), // Algorithm identifier sequence
Buffer.from([0x06, 0x03, 0x2b, 0x65, 0x70]), // Ed25519 OID
Buffer.from([0x03, 0x21, 0x00]), // Public key bit string
rawPublicKey,
]),
format: 'der',
type: 'spki',
});
// Verify the signature
const isValid = crypto.verify(null, challenge, publicKey, signature);
console.log(`🔐 Ed25519 signature verification: ${isValid ? 'PASSED' : 'FAILED'}`);
return isValid;
}
console.error(`Unsupported key type: ${keyType}`);
return false;
} catch (error) {
console.error('SSH signature verification failed:', error);
return false;
}
}
/**
* Check if SSH key is authorized for user
*/
private async checkSSHKeyAuthorization(userId: string, publicKey: string): Promise<boolean> {
try {
const os = require('os');
const fs = require('fs');
const path = require('path');
// Check user's authorized_keys file
const homeDir = userId === process.env.USER ? os.homedir() : `/home/${userId}`;
const authorizedKeysPath = path.join(homeDir, '.ssh', 'authorized_keys');
if (!fs.existsSync(authorizedKeysPath)) {
return false;
}
const authorizedKeys = fs.readFileSync(authorizedKeysPath, 'utf8');
const keyParts = publicKey.trim().split(' ');
const keyData = keyParts.length > 1 ? keyParts[1] : keyParts[0];
// Check if the key exists in authorized_keys
return authorizedKeys.includes(keyData);
} catch (error) {
console.error('Error checking SSH key authorization:', error);
return false;
}
}
/**
* Get current system user
*/
getCurrentUser(): string {
return process.env.USER || process.env.USERNAME || 'unknown';
}
/**
* Check if user exists on system
*/
async userExists(userId: string): Promise<boolean> {
try {
const { spawnSync } = require('child_process');
const result = spawnSync('id', [userId], { stdio: 'ignore' });
return result.status === 0;
} catch (_error) {
return false;
}
}
}

11
web/src/types/authenticate-pam.d.ts vendored Normal file
View file

@ -0,0 +1,11 @@
declare module 'authenticate-pam' {
interface AuthenticateCallback {
(error: Error | null, authenticated?: boolean): void;
}
export function authenticate(
username: string,
password: string,
callback: AuthenticateCallback
): void;
}

View file

@ -21,6 +21,7 @@
"include": [
"src/server/**/*",
"src/shared/**/*",
"src/types/**/*",
"src/cli.ts"
],
"exclude": [