mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-27 15:17:38 +00:00
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:
parent
24416d2c27
commit
e9b395b726
26 changed files with 3074 additions and 843 deletions
280
docs/authentication.md
Normal file
280
docs/authentication.md
Normal 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
|
||||
10
web/.claude/settings.local.json
Normal file
10
web/.claude/settings.local.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(git -C .. pull)",
|
||||
"Bash(rg:*)",
|
||||
"Bash(grep:*)"
|
||||
],
|
||||
"deny": []
|
||||
}
|
||||
}
|
||||
809
web/package-lock.json
generated
809
web/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
295
web/src/client/components/auth-login.ts
Normal file
295
web/src/client/components/auth-login.ts
Normal 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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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'))}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
|
|
|
|||
432
web/src/client/components/ssh-key-manager.ts
Normal file
432
web/src/client/components/ssh-key-manager.ts
Normal 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----- ... -----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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
385
web/src/client/services/auth-client.ts
Normal file
385
web/src/client/services/auth-client.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
456
web/src/client/services/ssh-agent.ts
Normal file
456
web/src/client/services/ssh-agent.ts
Normal 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}":`);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 */
|
||||
|
|
|
|||
7
web/src/client/types/authenticate-pam.d.ts
vendored
Normal file
7
web/src/client/types/authenticate-pam.d.ts
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
declare module 'authenticate-pam' {
|
||||
export function authenticate(
|
||||
username: string,
|
||||
password: string,
|
||||
callback: (err: Error | null) => void
|
||||
): void;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
};
|
||||
}
|
||||
|
|
|
|||
267
web/src/server/routes/auth.ts
Normal file
267
web/src/server/routes/auth.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
302
web/src/server/services/auth-service.ts
Normal file
302
web/src/server/services/auth-service.ts
Normal 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
11
web/src/types/authenticate-pam.d.ts
vendored
Normal 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;
|
||||
}
|
||||
|
|
@ -21,6 +21,7 @@
|
|||
"include": [
|
||||
"src/server/**/*",
|
||||
"src/shared/**/*",
|
||||
"src/types/**/*",
|
||||
"src/cli.ts"
|
||||
],
|
||||
"exclude": [
|
||||
|
|
|
|||
Loading…
Reference in a new issue