Skip to content
Open
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
176 changes: 176 additions & 0 deletions packages/opencode/src/auth/token.ts
Original file line number Diff line number Diff line change
@@ -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<typeof Permission>

export const ExpiryDuration = z.enum(["30d", "90d", "180d", "1y", "never"])
export type ExpiryDuration = z.infer<typeof ExpiryDuration>

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<typeof Info>

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<Exclude<ExpiryDuration, "never">, 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<ExpiryDuration, "never">]
}

export async function create(input: {
permissions: Permission[]
expiry: ExpiryDuration
name?: string
}): Promise<Info> {
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<Info[]> {
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<Info | undefined> {
const tokens = await all()
return tokens.find((t) => t.token === token)
}

export async function remove(token: string): Promise<boolean> {
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<Info> {
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<void> {
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<Info> {
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
}
}
150 changes: 149 additions & 1 deletion packages/opencode/src/cli/cmd/auth.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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() {},
})

Expand Down Expand Up @@ -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")
},
})
1 change: 1 addition & 0 deletions packages/opencode/src/cli/cmd/tui/context/sdk.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
13 changes: 11 additions & 2 deletions packages/opencode/src/cli/cmd/tui/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -97,7 +103,10 @@ export const rpc = {
async fetch(input: { url: string; method: string; headers: Record<string, string>; 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)
Expand Down
8 changes: 8 additions & 0 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading