diff --git a/packages/opencode/src/auth/token.ts b/packages/opencode/src/auth/token.ts new file mode 100644 index 00000000000..a6cef29f121 --- /dev/null +++ b/packages/opencode/src/auth/token.ts @@ -0,0 +1,176 @@ +import path from "path" +import { Global } from "../global" +import fs from "fs/promises" +import z from "zod" +import { NamedError } from "@opencode-ai/util/error" +import crypto from "crypto" + +export namespace AuthToken { + export const Permission = z.enum(["read", "write", "execute"]) + export type Permission = z.infer + + export const ExpiryDuration = z.enum(["30d", "90d", "180d", "1y", "never"]) + export type ExpiryDuration = z.infer + + export const Info = z + .object({ + token: z.string().min(128, "Token must be at least 128 characters"), + permissions: z.array(Permission), + expiresAt: z.number().nullable(), + createdAt: z.number(), + name: z.string().optional(), + }) + .meta({ ref: "AuthToken" }) + export type Info = z.infer + + export const InvalidTokenError = NamedError.create( + "InvalidTokenError", + z.object({ + message: z.string(), + }), + ) + + export const ExpiredTokenError = NamedError.create( + "ExpiredTokenError", + z.object({ + message: z.string(), + expiredAt: z.number(), + }), + ) + + export const InsufficientPermissionsError = NamedError.create( + "InsufficientPermissionsError", + z.object({ + message: z.string(), + required: z.array(z.string()), + granted: z.array(z.string()), + }), + ) + + // Computed at call time to support test isolation + function getFilepath(): string { + return path.join(Global.Path.config, "auth-tokens.json") + } + + function generateToken(): string { + // Generate 768-bit (96 bytes) cryptographically secure random token + // Results in 128 characters when base64url encoded + return crypto.randomBytes(96).toString("base64url") + } + + function calculateExpiry(duration: ExpiryDuration): number | null { + if (duration === "never") return null + + const now = Date.now() + const durations: Record, number> = { + "30d": 30 * 24 * 60 * 60 * 1000, + "90d": 90 * 24 * 60 * 60 * 1000, + "180d": 180 * 24 * 60 * 60 * 1000, + "1y": 365 * 24 * 60 * 60 * 1000, + } + + return now + durations[duration as Exclude] + } + + export async function create(input: { + permissions: Permission[] + expiry: ExpiryDuration + name?: string + }): Promise { + const token = generateToken() + const info: Info = { + token, + permissions: input.permissions, + expiresAt: calculateExpiry(input.expiry), + createdAt: Date.now(), + name: input.name, + } + + const tokens = await all() + tokens.push(info) + await save(tokens) + + return info + } + + export async function all(): Promise { + const file = Bun.file(getFilepath()) + const exists = await file.exists() + if (!exists) return [] + + const data = await file.json().catch(() => []) + if (!Array.isArray(data)) return [] + + return data + .map((item) => Info.safeParse(item)) + .filter((result) => result.success) + .map((result) => result.data) + } + + export async function get(token: string): Promise { + const tokens = await all() + return tokens.find((t) => t.token === token) + } + + export async function remove(token: string): Promise { + const tokens = await all() + const filtered = tokens.filter((t) => t.token !== token) + if (filtered.length === tokens.length) return false + + await save(filtered) + return true + } + + export async function validate(token: string, requiredPermissions: Permission[]): Promise { + const info = await get(token) + if (!info) { + throw new InvalidTokenError({ message: "Invalid authentication token" }) + } + + // Check expiry + if (info.expiresAt !== null && Date.now() > info.expiresAt) { + throw new ExpiredTokenError({ + message: "Authentication token has expired", + expiredAt: info.expiresAt, + }) + } + + // Check permissions + const hasAllPermissions = requiredPermissions.every((required) => info.permissions.includes(required)) + + if (!hasAllPermissions) { + throw new InsufficientPermissionsError({ + message: "Insufficient permissions for this operation", + required: requiredPermissions, + granted: info.permissions, + }) + } + + return info + } + + async function save(tokens: Info[]): Promise { + const file = Bun.file(getFilepath()) + await Bun.write(file, JSON.stringify(tokens, null, 2)) + await fs.chmod(file.name!, 0o600) + } + + export async function regenerate(oldToken: string, expiry?: ExpiryDuration): Promise { + const existing = await get(oldToken) + if (!existing) { + throw new InvalidTokenError({ message: "Token not found" }) + } + + // Create new token with same permissions + const newToken = await create({ + permissions: existing.permissions, + expiry: expiry ?? "never", + name: existing.name, + }) + + // Remove old token + await remove(oldToken) + + return newToken + } +} diff --git a/packages/opencode/src/cli/cmd/auth.ts b/packages/opencode/src/cli/cmd/auth.ts index 3dd7bcc35dd..8950bf43fef 100644 --- a/packages/opencode/src/cli/cmd/auth.ts +++ b/packages/opencode/src/cli/cmd/auth.ts @@ -1,4 +1,5 @@ import { Auth } from "../../auth" +import { AuthToken } from "../../auth/token" import { cmd } from "./cmd" import * as prompts from "@clack/prompts" import { UI } from "../ui" @@ -163,7 +164,12 @@ export const AuthCommand = cmd({ command: "auth", describe: "manage credentials", builder: (yargs) => - yargs.command(AuthLoginCommand).command(AuthLogoutCommand).command(AuthListCommand).demandCommand(), + yargs + .command(AuthLoginCommand) + .command(AuthLogoutCommand) + .command(AuthListCommand) + .command(AuthTokenCommand) + .demandCommand(), async handler() {}, }) @@ -398,3 +404,145 @@ export const AuthLogoutCommand = cmd({ prompts.outro("Logout successful") }, }) + +// Server token authentication commands +export const AuthTokenCommand = cmd({ + command: "token", + describe: "manage server authentication tokens", + builder: (yargs) => + yargs + .command(AuthTokenCreateCommand) + .command(AuthTokenListCommand) + .command(AuthTokenDeleteCommand) + .demandCommand(), + async handler() {}, +}) + +export const AuthTokenCreateCommand = cmd({ + command: "create", + describe: "create a new server authentication token", + builder: (yargs) => + yargs + .option("name", { + alias: "n", + type: "string", + describe: "optional name for the token", + }) + .option("permissions", { + alias: "p", + type: "array", + choices: ["read", "write", "execute"], + default: ["read", "write", "execute"], + describe: "permissions for the token", + }) + .option("expiry", { + alias: "e", + type: "string", + choices: ["30d", "90d", "180d", "1y", "never"], + default: "90d", + describe: "when the token expires", + }), + async handler(args) { + UI.empty() + prompts.intro("Create server token") + + const permissions = args.permissions as AuthToken.Permission[] + const expiry = args.expiry as AuthToken.ExpiryDuration + + const token = await AuthToken.create({ + permissions, + expiry, + name: args.name, + }) + + prompts.log.success("Token created successfully") + prompts.log.message("") + prompts.log.message(UI.Style.TEXT_NORMAL_BOLD + "Token:" + UI.Style.TEXT_NORMAL) + prompts.log.message(token.token) + prompts.log.message("") + prompts.log.warn("Store this token securely - it will not be shown again!") + prompts.log.message("") + prompts.log.info(`Permissions: ${permissions.join(", ")}`) + prompts.log.info(`Expires: ${token.expiresAt ? new Date(token.expiresAt).toLocaleDateString() : "never"}`) + if (token.name) { + prompts.log.info(`Name: ${token.name}`) + } + + prompts.outro("Done") + }, +}) + +export const AuthTokenListCommand = cmd({ + command: "list", + aliases: ["ls"], + describe: "list server authentication tokens", + async handler() { + UI.empty() + const tokenPath = path.join(Global.Path.config, "auth-tokens.json") + const homedir = os.homedir() + const displayPath = tokenPath.startsWith(homedir) ? tokenPath.replace(homedir, "~") : tokenPath + prompts.intro(`Server tokens ${UI.Style.TEXT_DIM}${displayPath}`) + + const tokens = await AuthToken.all() + + if (tokens.length === 0) { + prompts.log.warn("No tokens found") + prompts.log.info('Use "opencode auth token create" to create a token') + prompts.outro("0 tokens") + return + } + + for (const token of tokens) { + const masked = "..." + token.token.slice(-8) + const expired = token.expiresAt && Date.now() > token.expiresAt + const expiryStr = token.expiresAt ? new Date(token.expiresAt).toLocaleDateString() : "never" + const status = expired ? UI.Style.TEXT_DANGER + "[EXPIRED]" + UI.Style.TEXT_NORMAL : "" + + const name = token.name || "unnamed" + const permissions = token.permissions.join(",") + + prompts.log.info( + `${name} ${UI.Style.TEXT_DIM}${masked} [${permissions}] expires: ${expiryStr}${UI.Style.TEXT_NORMAL} ${status}`, + ) + } + + prompts.outro(`${tokens.length} token${tokens.length === 1 ? "" : "s"}`) + }, +}) + +export const AuthTokenDeleteCommand = cmd({ + command: "delete", + aliases: ["rm"], + describe: "delete a server authentication token", + async handler() { + UI.empty() + prompts.intro("Delete server token") + + const tokens = await AuthToken.all() + + if (tokens.length === 0) { + prompts.log.error("No tokens found") + prompts.outro("Done") + return + } + + const tokenToDelete = await prompts.select({ + message: "Select token to delete", + options: tokens.map((t) => ({ + label: (t.name || "unnamed") + UI.Style.TEXT_DIM + " (..." + t.token.slice(-8) + ")", + value: t.token, + })), + }) + + if (prompts.isCancel(tokenToDelete)) throw new UI.CancelledError() + + const removed = await AuthToken.remove(tokenToDelete) + if (removed) { + prompts.log.success("Token deleted") + } else { + prompts.log.error("Failed to delete token") + } + + prompts.outro("Done") + }, +}) diff --git a/packages/opencode/src/cli/cmd/tui/context/sdk.tsx b/packages/opencode/src/cli/cmd/tui/context/sdk.tsx index 3339e7b00d2..5f7b972de33 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sdk.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sdk.tsx @@ -11,6 +11,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ name: "SDK", init: (props: { url: string; directory?: string; fetch?: typeof fetch; events?: EventSource }) => { const abort = new AbortController() + const sdk = createOpencodeClient({ baseUrl: props.url, signal: abort.signal, diff --git a/packages/opencode/src/cli/cmd/tui/worker.ts b/packages/opencode/src/cli/cmd/tui/worker.ts index 343a5a3107f..ab6313440a6 100644 --- a/packages/opencode/src/cli/cmd/tui/worker.ts +++ b/packages/opencode/src/cli/cmd/tui/worker.ts @@ -49,7 +49,13 @@ const startEventStream = (directory: string) => { const signal = abort.signal const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => { - const request = new Request(input, init) + const request = new Request(input, { + ...init, + headers: { + ...Object.fromEntries(new Request(input, init).headers.entries()), + "x-opencode-internal": "true", + }, + }) return Server.App().fetch(request) }) as typeof globalThis.fetch @@ -97,7 +103,10 @@ export const rpc = { async fetch(input: { url: string; method: string; headers: Record; body?: string }) { const request = new Request(input.url, { method: input.method, - headers: input.headers, + headers: { + ...input.headers, + "x-opencode-internal": "true", + }, body: input.body, }) const response = await Server.App().fetch(request) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index bf4a6035bd8..58d26c75f6d 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -773,6 +773,14 @@ export namespace Config { hostname: z.string().optional().describe("Hostname to listen on"), mdns: z.boolean().optional().describe("Enable mDNS service discovery"), cors: z.array(z.string()).optional().describe("Additional domains to allow for CORS"), + auth: z + .object({ + // WARNING: Disabling auth is not recommended and is a security risk. + // When auth is disabled, anyone with network access can control your server. + enabled: z.boolean().optional().default(true).describe("Enable token-based authentication (default: true)"), + }) + .optional() + .describe("Authentication configuration for the server"), }) .strict() .meta({ diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 52457515b8e..5bdc556744f 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -26,6 +26,7 @@ import { Project } from "../project/project" import { Vcs } from "../project/vcs" import { Agent } from "../agent/agent" import { Auth } from "../auth" +import { AuthToken } from "../auth/token" import { Flag } from "../flag/flag" import { Command } from "../command" import { ProviderAuth } from "../provider/auth" @@ -141,6 +142,77 @@ export namespace Server { }, }), ) + .use(async (c, next) => { + // Token-based authentication middleware + // Use global config since we're before Instance.provide middleware + const config = await Config.global() + // Auth is enabled by default for security. + // WARNING: Disabling auth is not recommended and is a security risk. + const authEnabled = config.server?.auth?.enabled ?? true + + if (!authEnabled) { + return next() + } + + // Internal requests (from worker RPC) bypass auth + // These are same-process calls that don't go through HTTP + if (c.req.header("x-opencode-internal") === "true") { + return next() + } + + // Health endpoint is always accessible + if (c.req.path === "/global/health") { + return next() + } + + const authHeader = c.req.header("Authorization") + if (!authHeader || !authHeader.startsWith("Bearer ")) { + return c.json( + { + name: "AuthenticationError", + message: "Missing or invalid Authorization header. Expected format: Bearer ", + properties: {}, + }, + { status: 401 }, + ) + } + + const token = authHeader.substring(7) // Remove "Bearer " prefix + + // Validate as a persistent token with permission checks + try { + // Determine required permissions based on HTTP method + const requiredPermissions: AuthToken.Permission[] = [] + if (c.req.method === "GET" || c.req.method === "HEAD") { + requiredPermissions.push("read") + } + if (c.req.method === "POST" || c.req.method === "PUT" || c.req.method === "PATCH") { + requiredPermissions.push("write") + } + if (c.req.method === "DELETE") { + requiredPermissions.push("write") + } + + // Execute permission is required for certain endpoints + if (c.req.path.includes("/pty") || c.req.path.includes("/session") || c.req.path.includes("/message")) { + requiredPermissions.push("execute") + } + + await AuthToken.validate(token, requiredPermissions) + return next() + } catch (err) { + if (err instanceof NamedError) { + let status: ContentfulStatusCode = 401 + if (err instanceof AuthToken.ExpiredTokenError) { + status = 401 + } else if (err instanceof AuthToken.InsufficientPermissionsError) { + status = 403 + } + return c.json(err.toObject(), { status }) + } + throw err + } + }) .get( "/global/health", describeRoute({ @@ -229,6 +301,33 @@ export namespace Server { }) }, ) + .get( + "/auth/token", + describeRoute({ + summary: "List auth tokens", + description: "Get all authentication tokens (tokens are masked for security). Requires authentication.", + operationId: "auth.token.list", + responses: { + 200: { + description: "List of tokens", + content: { + "application/json": { + schema: resolver(z.array(AuthToken.Info).meta({ ref: "AuthTokens" })), + }, + }, + }, + }, + }), + async (c) => { + const tokens = await AuthToken.all() + // Mask tokens for security (show only last 8 chars) + const maskedTokens = tokens.map((t) => ({ + ...t, + token: "..." + t.token.slice(-8), + })) + return c.json(maskedTokens) + }, + ) .post( "/global/dispose", describeRoute({ diff --git a/packages/opencode/test/server/session-select.test.ts b/packages/opencode/test/server/session-select.test.ts index 479be4a17f8..09879a93d2c 100644 --- a/packages/opencode/test/server/session-select.test.ts +++ b/packages/opencode/test/server/session-select.test.ts @@ -1,13 +1,32 @@ -import { describe, expect, test } from "bun:test" +import { describe, expect, test, beforeAll, afterAll } from "bun:test" import path from "path" import { Session } from "../../src/session" import { Log } from "../../src/util/log" import { Instance } from "../../src/project/instance" import { Server } from "../../src/server/server" +import { AuthToken } from "../../src/auth/token" const projectRoot = path.join(__dirname, "../..") Log.init({ print: false }) +// Test auth token for authenticated requests +let testToken: string + +beforeAll(async () => { + const tokenInfo = await AuthToken.create({ + permissions: ["read", "write", "execute"], + expiry: "never", + name: "test-token", + }) + testToken = tokenInfo.token +}) + +afterAll(async () => { + if (testToken) { + await AuthToken.remove(testToken) + } +}) + describe("tui.selectSession endpoint", () => { test("should return 200 when called with valid session", async () => { await Instance.provide({ @@ -20,7 +39,10 @@ describe("tui.selectSession endpoint", () => { const app = Server.App() const response = await app.request("/tui/select-session", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${testToken}`, + }, body: JSON.stringify({ sessionID: session.id }), }) @@ -45,7 +67,10 @@ describe("tui.selectSession endpoint", () => { const app = Server.App() const response = await app.request("/tui/select-session", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${testToken}`, + }, body: JSON.stringify({ sessionID: nonExistentSessionID }), }) @@ -66,7 +91,10 @@ describe("tui.selectSession endpoint", () => { const app = Server.App() const response = await app.request("/tui/select-session", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${testToken}`, + }, body: JSON.stringify({ sessionID: invalidSessionID }), }) @@ -75,4 +103,22 @@ describe("tui.selectSession endpoint", () => { }, }) }) + + test("should return 401 when no auth token provided", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + // #when + const app = Server.App() + const response = await app.request("/tui/select-session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sessionID: "ses_test123" }), + }) + + // #then + expect(response.status).toBe(401) + }, + }) + }) })