Skip to content

Commit b02042a

Browse files
authored
feat(auth): add bearer token authentication (#145)
1 parent 3c0cb7a commit b02042a

File tree

14 files changed

+692
-23
lines changed

14 files changed

+692
-23
lines changed

mobile/src/lib/api.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,11 +126,13 @@ type ServerMode = 'real' | 'demo';
126126
interface ServerConfig {
127127
host: string;
128128
port: number;
129+
token?: string;
129130
mode?: ServerMode;
130131
}
131132

132133
let baseUrl = '';
133134
let serverMode: ServerMode = 'real';
135+
let currentToken: string | undefined;
134136

135137
export function setBaseUrl(url: string): void {
136138
baseUrl = url;
@@ -173,32 +175,50 @@ export async function loadServerConfig(): Promise<ServerConfig | null> {
173175
const mode = resolveMode(config.host, config.mode);
174176

175177
baseUrl = `http://${normalizeHost(config.host)}:${config.port}`;
178+
currentToken = config.token;
176179
client = createClient();
177180
setServerMode(mode);
178181
setUserContext(baseUrl);
179182

180183
return { ...config, mode };
181184
}
182185

183-
export async function saveServerConfig(host: string, port: number = DEFAULT_PORT): Promise<void> {
186+
export async function saveServerConfig(
187+
host: string,
188+
port: number = DEFAULT_PORT,
189+
token?: string
190+
): Promise<void> {
184191
const mode = resolveMode(host);
185-
const config: ServerConfig = { host, port, mode };
192+
const config: ServerConfig = { host, port, token, mode };
186193

187194
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(config));
188195

189196
baseUrl = `http://${normalizeHost(host)}:${port}`;
197+
currentToken = token;
190198
client = createClient();
191199
setServerMode(mode);
192200
setUserContext(baseUrl);
193201
}
194202

203+
export function getToken(): string | undefined {
204+
return currentToken;
205+
}
206+
195207
export function getDefaultPort(): number {
196208
return DEFAULT_PORT;
197209
}
198210

199211
function createClient() {
212+
const token = currentToken;
200213
const link = new RPCLink({
201214
url: `${baseUrl}/rpc`,
215+
fetch: (url, init) => {
216+
const headers = new Headers((init as RequestInit)?.headers);
217+
if (token) {
218+
headers.set('Authorization', `Bearer ${token}`);
219+
}
220+
return fetch(url, { ...(init as RequestInit), headers });
221+
},
202222
});
203223

204224
return createORPCClient<{

src/agent/auth.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import type { AgentConfig } from '../shared/types';
2+
import { getTailscaleIdentity } from '../tailscale';
3+
4+
export interface AuthResult {
5+
ok: boolean;
6+
identity?: { type: 'token' | 'tailscale'; user?: string };
7+
}
8+
9+
const PUBLIC_PATHS = ['/health'];
10+
11+
const WEB_UI_PATTERNS = [/^\/$/, /^\/index\.html$/, /^\/assets\//, /^\/favicon\.ico$/];
12+
13+
function isPublicPath(pathname: string): boolean {
14+
if (PUBLIC_PATHS.includes(pathname)) {
15+
return true;
16+
}
17+
return WEB_UI_PATTERNS.some((pattern) => pattern.test(pathname));
18+
}
19+
20+
export function checkAuth(req: Request, config: AgentConfig): AuthResult {
21+
const url = new URL(req.url);
22+
23+
if (isPublicPath(url.pathname)) {
24+
return { ok: true };
25+
}
26+
27+
if (!config.auth?.token) {
28+
return { ok: true };
29+
}
30+
31+
const tsIdentity = getTailscaleIdentity(req);
32+
if (tsIdentity) {
33+
return { ok: true, identity: { type: 'tailscale', user: tsIdentity.email } };
34+
}
35+
36+
const authHeader = req.headers.get('Authorization');
37+
if (authHeader?.startsWith('Bearer ')) {
38+
const token = authHeader.slice(7);
39+
if (token === config.auth.token) {
40+
return { ok: true, identity: { type: 'token' } };
41+
}
42+
}
43+
44+
return { ok: false };
45+
}
46+
47+
export function unauthorizedResponse(): Response {
48+
return new Response('Unauthorized', {
49+
status: 401,
50+
headers: {
51+
'WWW-Authenticate': 'Bearer',
52+
'Access-Control-Allow-Origin': '*',
53+
},
54+
});
55+
}

src/agent/run.ts

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import type { Server, ServerWebSocket } from 'bun';
2+
import crypto from 'crypto';
3+
import fs from 'fs';
4+
import path from 'path';
25
import { RPCHandler } from '@orpc/server/fetch';
3-
import { loadAgentConfig, getConfigDir, ensureConfigDir } from '../config/loader';
6+
import { loadAgentConfig, saveAgentConfig, getConfigDir, ensureConfigDir } from '../config/loader';
47
import type { AgentConfig } from '../shared/types';
8+
import { CONFIG_FILE } from '../shared/types';
59
import { HOST_WORKSPACE_NAME } from '../shared/client-types';
610
import { DEFAULT_AGENT_PORT } from '../shared/constants';
711
import { WorkspaceManager } from '../workspace/manager';
@@ -14,6 +18,7 @@ import { serveStaticBun } from './static';
1418
import { SessionsCacheManager } from '../sessions/cache';
1519
import { ModelCacheManager } from '../models/cache';
1620
import { FileWatcher } from './file-watcher';
21+
import { checkAuth, unauthorizedResponse } from './auth';
1722
import {
1823
getTailscaleStatus,
1924
getTailscaleIdentity,
@@ -125,13 +130,23 @@ function createAgentServer(
125130
const corsHeaders = {
126131
'Access-Control-Allow-Origin': '*',
127132
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
128-
'Access-Control-Allow-Headers': 'Content-Type',
133+
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
129134
};
130135

131136
if (method === 'OPTIONS') {
132137
return new Response(null, { status: 204, headers: corsHeaders });
133138
}
134139

140+
const staticResponse = await serveStaticBun(pathname);
141+
if (staticResponse) {
142+
return staticResponse;
143+
}
144+
145+
const authResult = checkAuth(req, currentConfig);
146+
if (!authResult.ok) {
147+
return unauthorizedResponse();
148+
}
149+
135150
const terminalMatch = pathname.match(/^\/rpc\/terminal\/([^/]+)$/);
136151

137152
if (terminalMatch) {
@@ -177,11 +192,6 @@ function createAgentServer(
177192
}
178193
}
179194

180-
const staticResponse = await serveStaticBun(pathname);
181-
if (staticResponse) {
182-
return staticResponse;
183-
}
184-
185195
return Response.json({ error: 'Not found' }, { status: 404, headers: corsHeaders });
186196
},
187197

@@ -252,12 +262,31 @@ const BANNER = `
252262
|_| |_____|_| \\_\\_| \\_\\|_|
253263
`;
254264

265+
async function ensureAuthForNewInstalls(configDir: string): Promise<AgentConfig> {
266+
const configPath = path.join(configDir, CONFIG_FILE);
267+
const configExists = fs.existsSync(configPath);
268+
269+
const config = await loadAgentConfig(configDir);
270+
271+
if (!configExists && !config.auth?.token) {
272+
const token = `perry-${crypto.randomBytes(16).toString('hex')}`;
273+
config.auth = { ...config.auth, token };
274+
await saveAgentConfig(config, configDir);
275+
276+
console.log(`[agent] New install detected - auth enabled by default`);
277+
console.log(`[agent] Auth token generated: ${token}`);
278+
console.log(`[agent] Configure clients with: perry config token ${token}`);
279+
}
280+
281+
return config;
282+
}
283+
255284
export async function startAgent(options: StartAgentOptions = {}): Promise<void> {
256285
const configDir = options.configDir || getConfigDir();
257286

258287
await ensureConfigDir(configDir);
259288

260-
const config = await loadAgentConfig(configDir);
289+
const config = await ensureAuthForNewInstalls(configDir);
261290

262291
if (options.noHostAccess || process.env.PERRY_NO_HOST_ACCESS === 'true') {
263292
config.allowHostAccess = false;

src/agent/static.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,12 @@ export async function serveStatic(
7777
return true;
7878
}
7979

80+
const API_PREFIXES = ['/rpc', '/health'];
81+
82+
function isApiPath(pathname: string): boolean {
83+
return API_PREFIXES.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`));
84+
}
85+
8086
export async function serveStaticBun(pathname: string): Promise<Response | null> {
8187
const webDir = getWebDir();
8288
const indexPath = path.join(webDir, 'index.html');
@@ -106,6 +112,10 @@ export async function serveStaticBun(pathname: string): Promise<Response | null>
106112
}
107113
}
108114

115+
if (isApiPath(pathname)) {
116+
return null;
117+
}
118+
109119
const file = Bun.file(indexPath);
110120
return new Response(file, {
111121
headers: { 'Content-Type': 'text/html' },

src/client/api.ts

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { DEFAULT_AGENT_PORT } from '../shared/constants';
1616
export interface ApiClientOptions {
1717
baseUrl: string;
1818
timeout?: number;
19+
token?: string;
1920
}
2021

2122
export class ApiClientError extends Error {
@@ -34,21 +35,38 @@ type Client = RouterClient<AppRouter>;
3435
export class ApiClient {
3536
private baseUrl: string;
3637
private client: Client;
38+
private token?: string;
3739

3840
constructor(options: ApiClientOptions) {
3941
this.baseUrl = options.baseUrl.replace(/\/$/, '');
42+
this.token = options.token;
4043

44+
const token = this.token;
45+
const timeout = options.timeout || 30000;
4146
const link = new RPCLink({
4247
url: `${this.baseUrl}/rpc`,
43-
fetch: (url, init) =>
44-
fetch(url, { ...init, signal: AbortSignal.timeout(options.timeout || 30000) }),
48+
fetch: (url, init) => {
49+
const headers = new Headers((init as RequestInit)?.headers);
50+
if (token) {
51+
headers.set('Authorization', `Bearer ${token}`);
52+
}
53+
return fetch(url, {
54+
...(init as RequestInit),
55+
headers,
56+
signal: AbortSignal.timeout(timeout),
57+
});
58+
},
4559
});
4660

4761
this.client = createORPCClient<Client>(link);
4862
}
4963

5064
async health(): Promise<{ status: string; version: string }> {
51-
const response = await fetch(`${this.baseUrl}/health`);
65+
const headers: Record<string, string> = {};
66+
if (this.token) {
67+
headers['Authorization'] = `Bearer ${this.token}`;
68+
}
69+
const response = await fetch(`${this.baseUrl}/health`, { headers });
5270
return response.json();
5371
}
5472

@@ -257,7 +275,12 @@ export function formatWorkerBaseUrl(worker: string, port?: number): string {
257275
return `http://${trimmed}:${effectivePort}`;
258276
}
259277

260-
export function createApiClient(worker: string, port?: number, timeoutMs?: number): ApiClient {
278+
export function createApiClient(
279+
worker: string,
280+
port?: number,
281+
timeoutMs?: number,
282+
token?: string
283+
): ApiClient {
261284
const baseUrl = formatWorkerBaseUrl(worker, port);
262-
return new ApiClient({ baseUrl, timeout: timeoutMs });
285+
return new ApiClient({ baseUrl, timeout: timeoutMs, token });
263286
}

src/client/config.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,17 @@ export async function setAgent(agent: string, configDir?: string): Promise<void>
4646
await saveClientConfig(config, configDir);
4747
}
4848

49+
export async function getToken(configDir?: string): Promise<string | null> {
50+
const config = await loadClientConfig(configDir);
51+
return config?.token || null;
52+
}
53+
54+
export async function setToken(token: string, configDir?: string): Promise<void> {
55+
const config = (await loadClientConfig(configDir)) || {};
56+
config.token = token;
57+
await saveClientConfig(config, configDir);
58+
}
59+
4960
// Legacy aliases for backwards compatibility
5061
export const getWorker = getAgent;
5162
export const setWorker = setAgent;

src/commands/auth.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import crypto from 'crypto';
2+
import { loadAgentConfig, saveAgentConfig, getConfigDir } from '../config/loader';
3+
4+
export async function authInit(): Promise<void> {
5+
const configDir = getConfigDir();
6+
const config = await loadAgentConfig(configDir);
7+
8+
if (config.auth?.token) {
9+
console.log('Auth token already exists.');
10+
console.log('To regenerate, remove auth.token from config.json first.');
11+
return;
12+
}
13+
14+
const token = `perry-${crypto.randomBytes(16).toString('hex')}`;
15+
config.auth = { ...config.auth, token };
16+
await saveAgentConfig(config, configDir);
17+
18+
console.log(`Auth token generated: ${token}`);
19+
console.log(`Configure clients with: perry config token ${token}`);
20+
console.log('Restart the agent for auth to take effect.');
21+
}

src/config/loader.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ export async function loadAgentConfig(configDir?: string): Promise<AgentConfig>
110110
workspaces: config.ssh?.workspaces || {},
111111
},
112112
tailscale,
113+
auth: config.auth,
113114
};
114115
} catch (err: unknown) {
115116
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {

0 commit comments

Comments
 (0)