Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions docs/docs/configuration/authentication.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# Authentication

Perry supports bearer token authentication to secure API access. When enabled, all API requests must include a valid authentication token.

## Overview

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:

- Prevent unauthorized access to workspace management
- Secure remote access via Tailscale or other networks
- Protect sensitive credentials stored in workspaces

## Generating a Token

### Using the CLI

During initial setup or reconfiguration:

```bash
perry setup
```

Follow the prompts to generate an authentication token. The token will be displayed once and stored securely.

Alternatively, generate a token directly:

```bash
perry auth generate
```

### Using the Web UI

1. Open the Perry web interface
2. Navigate to **Settings > Security**
3. Click **Generate Token** (or **Regenerate Token** if one exists)
4. Copy the displayed token immediately - it won't be shown again

## Configuring Clients

### CLI Configuration

When running `perry setup` against a remote agent with authentication enabled, you'll be prompted to enter the token:

```bash
perry setup --agent http://remote-host:6660
# Enter token when prompted
```

The token is stored in `~/.config/perry/config.json`.

### Web UI

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.

### API Requests

Include the token in the `Authorization` header:

```bash
curl -H "Authorization: Bearer <your-token>" \
http://localhost:6660/rpc/workspaces.list
```

## Disabling Authentication

### Using the CLI

```bash
perry auth disable
```

### Using the Web UI

1. Navigate to **Settings > Security**
2. Click **Disable Authentication**
3. Confirm the action in the dialog

:::warning
Disabling authentication allows anyone with network access to control your Perry agent. Only disable authentication on trusted networks or for local-only access.
:::

## Regenerating Tokens

If you suspect a token has been compromised, regenerate it immediately:

1. Generate a new token (CLI or Web UI)
2. Update all clients with the new token
3. The old token is automatically invalidated

## Security Considerations

### Network Exposure

- **Local only**: Authentication is optional but recommended
- **Tailscale/VPN**: Enable authentication to protect against compromised tailnet members
- **Public internet**: Always enable authentication and consider additional security measures

### Token Storage

- CLI: Stored in `~/.config/perry/config.json` with file permissions `600`
- Web UI: Stored in browser local storage
- Agent: Stored in the agent's configuration file

### Best Practices

1. Generate unique tokens for each deployment
2. Regenerate tokens periodically
3. Use environment variables for automation instead of hardcoding tokens
4. Monitor access logs for suspicious activity
1 change: 1 addition & 0 deletions docs/sidebars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const sidebars: SidebarsConfig = {
'configuration/scripts',
'configuration/tailscale',
'configuration/github',
'configuration/authentication',
],
},
{
Expand Down
10 changes: 10 additions & 0 deletions src/agent/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -680,6 +680,15 @@ export function createRouter(ctx: RouterContext) {
return { token };
});

const disableAuth = os.output(z.object({ success: z.boolean() })).handler(async () => {
const currentConfig = ctx.config.get();
const { token: _, ...restAuth } = currentConfig.auth || {};
const newConfig = { ...currentConfig, auth: restAuth };
ctx.config.set(newConfig);
await saveAgentConfig(newConfig, ctx.configDir);
return { success: true };
});

const GitHubRepoSchema = z.object({
name: z.string(),
fullName: z.string(),
Expand Down Expand Up @@ -1580,6 +1589,7 @@ export function createRouter(ctx: RouterContext) {
auth: {
get: getAuthConfig,
generate: generateAuthToken,
disable: disableAuth,
},
},
};
Expand Down
79 changes: 79 additions & 0 deletions test/integration/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,3 +191,82 @@ describe('Auth Middleware - No Token Configured', () => {
expect(result.code).toBe(404);
});
});

describe('Auth Token Management', () => {
let agent: TestAgent;
const INITIAL_TOKEN = 'initial-test-token';

beforeAll(async () => {
agent = await startTestAgent({
config: {
auth: { token: INITIAL_TOKEN },
},
});
}, 30000);

afterAll(async () => {
if (agent) {
await agent.cleanup();
}
});

it('can disable authentication', async () => {
const disableResponse = await fetch(`${agent.baseUrl}/rpc/config/auth/disable`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${INITIAL_TOKEN}`,
},
body: JSON.stringify({}),
});

expect(disableResponse.status).toBe(200);
const disableResult = await disableResponse.json();
expect(disableResult.json.success).toBe(true);

const infoResponse = await fetch(`${agent.baseUrl}/rpc/info`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});

expect(infoResponse.status).toBe(200);
});

it('auth config shows no token after disabling', async () => {
const response = await fetch(`${agent.baseUrl}/rpc/config/auth/get`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});

expect(response.status).toBe(200);
const result = await response.json();
expect(result.json.hasToken).toBe(false);
expect(result.json.tokenPreview).toBeUndefined();
});

it('can generate a new token after disabling', async () => {
const generateResponse = await fetch(`${agent.baseUrl}/rpc/config/auth/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});

expect(generateResponse.status).toBe(200);
const result = await generateResponse.json();
expect(result.json.token).toBeDefined();
expect(result.json.token.length).toBe(24);

const infoResponse = await fetch(`${agent.baseUrl}/rpc/info`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${result.json.token}`,
},
body: JSON.stringify({}),
});

expect(infoResponse.status).toBe(200);
});
});
2 changes: 2 additions & 0 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { SSHSettings } from './pages/settings/SSH';
import { TerminalSettings } from './pages/settings/Terminal';
import { GitHubSettings } from './pages/settings/GitHub';
import { TailscaleSettings } from './pages/settings/Tailscale';
import { SecuritySettings } from './pages/settings/Security';
import { Setup } from './pages/Setup';
import { Skills } from './pages/Skills';
import { McpServers } from './pages/McpServers';
Expand Down Expand Up @@ -124,6 +125,7 @@ function App() {
<Route path="settings/terminal" element={<TerminalSettings />} />
<Route path="settings/github" element={<GitHubSettings />} />
<Route path="settings/tailscale" element={<TailscaleSettings />} />
<Route path="settings/security" element={<SecuritySettings />} />
<Route path="skills" element={<Skills />} />
<Route path="mcp" element={<McpServers />} />
</Route>
Expand Down
2 changes: 2 additions & 0 deletions web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
Github,
Network,
ChevronDown,
Shield,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { api, type WorkspaceInfo } from '@/lib/api';
Expand Down Expand Up @@ -48,6 +49,7 @@ export function Sidebar({ isOpen, onToggle }: SidebarProps) {
{ to: '/settings/ssh', label: 'SSH Keys', icon: KeyRound },
{ to: '/settings/scripts', label: 'Scripts', icon: Terminal },
{ to: '/settings/terminal', label: 'Terminal', icon: SquareTerminal },
{ to: '/settings/security', label: 'Security', icon: Shield },
];

const integrationLinks = [
Expand Down
2 changes: 2 additions & 0 deletions web/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@ type ClientType = {
auth: {
get: () => Promise<{ hasToken: boolean; tokenPreview?: string }>;
generate: () => Promise<{ token: string }>;
disable: () => Promise<{ success: boolean }>;
};
};
};
Expand Down Expand Up @@ -298,6 +299,7 @@ export const api = {
updateMcpServers: (data: McpServer[]) => client.config.mcp.update(data),
getAuthConfig: () => client.config.auth.get(),
generateAuthToken: () => client.config.auth.generate(),
disableAuth: () => client.config.auth.disable(),
};

export function getTerminalUrl(name: string): string {
Expand Down
Loading
Loading