Skip to content

Commit 6b44078

Browse files
authored
feat(web): add security settings page for token management (#148)
1 parent 3555dbc commit 6b44078

File tree

8 files changed

+456
-0
lines changed

8 files changed

+456
-0
lines changed
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
# Authentication
2+
3+
Perry supports bearer token authentication to secure API access. When enabled, all API requests must include a valid authentication token.
4+
5+
## Overview
6+
7+
By default, Perry runs without authentication. This is convenient for local development but not recommended when the agent is accessible over a network. Enable authentication to:
8+
9+
- Prevent unauthorized access to workspace management
10+
- Secure remote access via Tailscale or other networks
11+
- Protect sensitive credentials stored in workspaces
12+
13+
## Generating a Token
14+
15+
### Using the CLI
16+
17+
During initial setup or reconfiguration:
18+
19+
```bash
20+
perry setup
21+
```
22+
23+
Follow the prompts to generate an authentication token. The token will be displayed once and stored securely.
24+
25+
Alternatively, generate a token directly:
26+
27+
```bash
28+
perry auth generate
29+
```
30+
31+
### Using the Web UI
32+
33+
1. Open the Perry web interface
34+
2. Navigate to **Settings > Security**
35+
3. Click **Generate Token** (or **Regenerate Token** if one exists)
36+
4. Copy the displayed token immediately - it won't be shown again
37+
38+
## Configuring Clients
39+
40+
### CLI Configuration
41+
42+
When running `perry setup` against a remote agent with authentication enabled, you'll be prompted to enter the token:
43+
44+
```bash
45+
perry setup --agent http://remote-host:6660
46+
# Enter token when prompted
47+
```
48+
49+
The token is stored in `~/.config/perry/config.json`.
50+
51+
### Web UI
52+
53+
When accessing the web UI of an agent with authentication enabled, you'll be prompted to enter the token. The token is stored in your browser's local storage.
54+
55+
### API Requests
56+
57+
Include the token in the `Authorization` header:
58+
59+
```bash
60+
curl -H "Authorization: Bearer <your-token>" \
61+
http://localhost:6660/rpc/workspaces.list
62+
```
63+
64+
## Disabling Authentication
65+
66+
### Using the CLI
67+
68+
```bash
69+
perry auth disable
70+
```
71+
72+
### Using the Web UI
73+
74+
1. Navigate to **Settings > Security**
75+
2. Click **Disable Authentication**
76+
3. Confirm the action in the dialog
77+
78+
:::warning
79+
Disabling authentication allows anyone with network access to control your Perry agent. Only disable authentication on trusted networks or for local-only access.
80+
:::
81+
82+
## Regenerating Tokens
83+
84+
If you suspect a token has been compromised, regenerate it immediately:
85+
86+
1. Generate a new token (CLI or Web UI)
87+
2. Update all clients with the new token
88+
3. The old token is automatically invalidated
89+
90+
## Security Considerations
91+
92+
### Network Exposure
93+
94+
- **Local only**: Authentication is optional but recommended
95+
- **Tailscale/VPN**: Enable authentication to protect against compromised tailnet members
96+
- **Public internet**: Always enable authentication and consider additional security measures
97+
98+
### Token Storage
99+
100+
- CLI: Stored in `~/.config/perry/config.json` with file permissions `600`
101+
- Web UI: Stored in browser local storage
102+
- Agent: Stored in the agent's configuration file
103+
104+
### Best Practices
105+
106+
1. Generate unique tokens for each deployment
107+
2. Regenerate tokens periodically
108+
3. Use environment variables for automation instead of hardcoding tokens
109+
4. Monitor access logs for suspicious activity

docs/sidebars.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ const sidebars: SidebarsConfig = {
2525
'configuration/scripts',
2626
'configuration/tailscale',
2727
'configuration/github',
28+
'configuration/authentication',
2829
],
2930
},
3031
{

src/agent/router.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -680,6 +680,15 @@ export function createRouter(ctx: RouterContext) {
680680
return { token };
681681
});
682682

683+
const disableAuth = os.output(z.object({ success: z.boolean() })).handler(async () => {
684+
const currentConfig = ctx.config.get();
685+
const { token: _, ...restAuth } = currentConfig.auth || {};
686+
const newConfig = { ...currentConfig, auth: restAuth };
687+
ctx.config.set(newConfig);
688+
await saveAgentConfig(newConfig, ctx.configDir);
689+
return { success: true };
690+
});
691+
683692
const GitHubRepoSchema = z.object({
684693
name: z.string(),
685694
fullName: z.string(),
@@ -1580,6 +1589,7 @@ export function createRouter(ctx: RouterContext) {
15801589
auth: {
15811590
get: getAuthConfig,
15821591
generate: generateAuthToken,
1592+
disable: disableAuth,
15831593
},
15841594
},
15851595
};

test/integration/auth.test.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,3 +191,82 @@ describe('Auth Middleware - No Token Configured', () => {
191191
expect(result.code).toBe(404);
192192
});
193193
});
194+
195+
describe('Auth Token Management', () => {
196+
let agent: TestAgent;
197+
const INITIAL_TOKEN = 'initial-test-token';
198+
199+
beforeAll(async () => {
200+
agent = await startTestAgent({
201+
config: {
202+
auth: { token: INITIAL_TOKEN },
203+
},
204+
});
205+
}, 30000);
206+
207+
afterAll(async () => {
208+
if (agent) {
209+
await agent.cleanup();
210+
}
211+
});
212+
213+
it('can disable authentication', async () => {
214+
const disableResponse = await fetch(`${agent.baseUrl}/rpc/config/auth/disable`, {
215+
method: 'POST',
216+
headers: {
217+
'Content-Type': 'application/json',
218+
Authorization: `Bearer ${INITIAL_TOKEN}`,
219+
},
220+
body: JSON.stringify({}),
221+
});
222+
223+
expect(disableResponse.status).toBe(200);
224+
const disableResult = await disableResponse.json();
225+
expect(disableResult.json.success).toBe(true);
226+
227+
const infoResponse = await fetch(`${agent.baseUrl}/rpc/info`, {
228+
method: 'POST',
229+
headers: { 'Content-Type': 'application/json' },
230+
body: JSON.stringify({}),
231+
});
232+
233+
expect(infoResponse.status).toBe(200);
234+
});
235+
236+
it('auth config shows no token after disabling', async () => {
237+
const response = await fetch(`${agent.baseUrl}/rpc/config/auth/get`, {
238+
method: 'POST',
239+
headers: { 'Content-Type': 'application/json' },
240+
body: JSON.stringify({}),
241+
});
242+
243+
expect(response.status).toBe(200);
244+
const result = await response.json();
245+
expect(result.json.hasToken).toBe(false);
246+
expect(result.json.tokenPreview).toBeUndefined();
247+
});
248+
249+
it('can generate a new token after disabling', async () => {
250+
const generateResponse = await fetch(`${agent.baseUrl}/rpc/config/auth/generate`, {
251+
method: 'POST',
252+
headers: { 'Content-Type': 'application/json' },
253+
body: JSON.stringify({}),
254+
});
255+
256+
expect(generateResponse.status).toBe(200);
257+
const result = await generateResponse.json();
258+
expect(result.json.token).toBeDefined();
259+
expect(result.json.token.length).toBe(24);
260+
261+
const infoResponse = await fetch(`${agent.baseUrl}/rpc/info`, {
262+
method: 'POST',
263+
headers: {
264+
'Content-Type': 'application/json',
265+
Authorization: `Bearer ${result.json.token}`,
266+
},
267+
body: JSON.stringify({}),
268+
});
269+
270+
expect(infoResponse.status).toBe(200);
271+
});
272+
});

web/src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { SSHSettings } from './pages/settings/SSH';
1919
import { TerminalSettings } from './pages/settings/Terminal';
2020
import { GitHubSettings } from './pages/settings/GitHub';
2121
import { TailscaleSettings } from './pages/settings/Tailscale';
22+
import { SecuritySettings } from './pages/settings/Security';
2223
import { Setup } from './pages/Setup';
2324
import { Skills } from './pages/Skills';
2425
import { McpServers } from './pages/McpServers';
@@ -124,6 +125,7 @@ function App() {
124125
<Route path="settings/terminal" element={<TerminalSettings />} />
125126
<Route path="settings/github" element={<GitHubSettings />} />
126127
<Route path="settings/tailscale" element={<TailscaleSettings />} />
128+
<Route path="settings/security" element={<SecuritySettings />} />
127129
<Route path="skills" element={<Skills />} />
128130
<Route path="mcp" element={<McpServers />} />
129131
</Route>

web/src/components/Sidebar.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
Github,
1717
Network,
1818
ChevronDown,
19+
Shield,
1920
} from 'lucide-react';
2021
import { cn } from '@/lib/utils';
2122
import { api, type WorkspaceInfo } from '@/lib/api';
@@ -48,6 +49,7 @@ export function Sidebar({ isOpen, onToggle }: SidebarProps) {
4849
{ to: '/settings/ssh', label: 'SSH Keys', icon: KeyRound },
4950
{ to: '/settings/scripts', label: 'Scripts', icon: Terminal },
5051
{ to: '/settings/terminal', label: 'Terminal', icon: SquareTerminal },
52+
{ to: '/settings/security', label: 'Security', icon: Shield },
5153
];
5254

5355
const integrationLinks = [

web/src/lib/api.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,7 @@ type ClientType = {
212212
auth: {
213213
get: () => Promise<{ hasToken: boolean; tokenPreview?: string }>;
214214
generate: () => Promise<{ token: string }>;
215+
disable: () => Promise<{ success: boolean }>;
215216
};
216217
};
217218
};
@@ -298,6 +299,7 @@ export const api = {
298299
updateMcpServers: (data: McpServer[]) => client.config.mcp.update(data),
299300
getAuthConfig: () => client.config.auth.get(),
300301
generateAuthToken: () => client.config.auth.generate(),
302+
disableAuth: () => client.config.auth.disable(),
301303
};
302304

303305
export function getTerminalUrl(name: string): string {

0 commit comments

Comments
 (0)