diff --git a/packages/server/README.md b/packages/server/README.md index 0649aa22..e69e9059 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -51,8 +51,17 @@ You can configure the server using flags or environment variables: | `--config ` | `CLI_CONFIG` | Config file location | | `--launch` | `CLI_LAUNCH` | Open the UI in a Chromium-based browser | | `--log-level ` | `CLI_LOG_LEVEL` | Logging level (trace, debug, info, warn, error) | +| `--username ` | `CODENOMAD_SERVER_USERNAME` | Username for CodeNomad's internal auth (default `codenomad`) | +| `--password ` | `CODENOMAD_SERVER_PASSWORD` | Password for CodeNomad's internal auth | +| `--generate-token` | `CODENOMAD_GENERATE_TOKEN` | Emit a one-time local bootstrap token for desktop flows | +| `--dangerously-skip-auth` | `CODENOMAD_SKIP_AUTH` | Disable CodeNomad's internal auth (use only behind a trusted perimeter) | + +### Authentication +- Default behavior: CodeNomad requires a login (username/password) and stores a session cookie in the browser. +- `--dangerously-skip-auth` / `CODENOMAD_SKIP_AUTH=true` disables the login prompt and treats all requests as authenticated. + Use this only when access is already protected by another layer (SSO proxy, VPN, Coder workspace auth, etc.). + If you bind to `0.0.0.0` while skipping auth, anyone who can reach the port can access the API. ### Data Storage - **Config**: `~/.config/codenomad/config.json` - **Instance Data**: `~/.config/codenomad/instances` (chat history, etc.) - diff --git a/packages/server/src/auth/manager.ts b/packages/server/src/auth/manager.ts index 55014d55..ebb7ec79 100644 --- a/packages/server/src/auth/manager.ts +++ b/packages/server/src/auth/manager.ts @@ -15,15 +15,25 @@ export interface AuthManagerInit { username: string password?: string generateToken: boolean + dangerouslySkipAuth?: boolean } export class AuthManager { - private readonly authStore: AuthStore + private readonly authStore: AuthStore | null private readonly tokenManager: TokenManager | null private readonly sessionManager = new SessionManager() private readonly cookieName = DEFAULT_AUTH_COOKIE_NAME + private readonly authEnabled: boolean constructor(private readonly init: AuthManagerInit, private readonly logger: Logger) { + this.authEnabled = !Boolean(init.dangerouslySkipAuth) + + if (!this.authEnabled) { + this.authStore = null + this.tokenManager = null + return + } + const authFilePath = resolveAuthFilePath(init.configPath) this.authStore = new AuthStore(authFilePath, logger.child({ component: "auth" })) @@ -37,6 +47,10 @@ export class AuthManager { this.tokenManager = init.generateToken ? new TokenManager(60_000) : null } + isAuthEnabled(): boolean { + return this.authEnabled + } + getCookieName(): string { return this.cookieName } @@ -56,19 +70,31 @@ export class AuthManager { } validateLogin(username: string, password: string): boolean { - return this.authStore.validateCredentials(username, password) + if (!this.authEnabled) { + return true + } + return this.requireAuthStore().validateCredentials(username, password) } createSession(username: string) { + if (!this.authEnabled) { + return { id: "auth-disabled", createdAt: Date.now(), username: this.init.username } + } return this.sessionManager.createSession(username) } getStatus() { - return this.authStore.getStatus() + if (!this.authEnabled) { + return { username: this.init.username, passwordUserProvided: false } + } + return this.requireAuthStore().getStatus() } setPassword(password: string) { - return this.authStore.setPassword({ password, markUserProvided: true }) + if (!this.authEnabled) { + throw new Error("Internal authentication is disabled") + } + return this.requireAuthStore().setPassword({ password, markUserProvided: true }) } isLoopbackRequest(request: FastifyRequest): boolean { @@ -76,6 +102,12 @@ export class AuthManager { } getSessionFromRequest(request: FastifyRequest): { username: string; sessionId: string } | null { + if (!this.authEnabled) { + // When auth is disabled, treat all requests as authenticated. + // We still return a stable username so callers can display it. + return { username: this.init.username, sessionId: "auth-disabled" } + } + const cookies = parseCookies(request.headers.cookie) const sessionId = cookies[this.cookieName] const session = this.sessionManager.getSession(sessionId) @@ -90,6 +122,13 @@ export class AuthManager { clearSessionCookie(reply: FastifyReply) { reply.header("Set-Cookie", buildSessionCookie(this.cookieName, "", { maxAgeSeconds: 0 })) } + + private requireAuthStore(): AuthStore { + if (!this.authStore) { + throw new Error("Auth store is unavailable") + } + return this.authStore + } } function resolveAuthFilePath(configPath: string) { diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 389b6198..9adffec4 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -44,6 +44,7 @@ interface CliOptions { authUsername: string authPassword?: string generateToken: boolean + dangerouslySkipAuth: boolean } const DEFAULT_PORT = 9898 @@ -84,6 +85,14 @@ function parseCliOptions(argv: string[]): CliOptions { .env("CODENOMAD_GENERATE_TOKEN") .default(false), ) + .addOption( + new Option( + "--dangerously-skip-auth", + "Disable CodeNomad's internal auth. Use only behind a trusted perimeter (SSO/VPN/etc).", + ) + .env("CODENOMAD_SKIP_AUTH") + .default(false), + ) program.parse(argv, { from: "user" }) const parsed = program.opts<{ @@ -104,8 +113,14 @@ function parseCliOptions(argv: string[]): CliOptions { username: string password?: string generateToken?: boolean + dangerouslySkipAuth?: boolean }>() + const parseBooleanEnv = (value: string | undefined): boolean => { + const normalized = (value ?? "").trim().toLowerCase() + return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "y" || normalized === "on" + } + const resolvedRoot = parsed.workspaceRoot ?? parsed.root ?? process.cwd() const normalizedHost = resolveHost(parsed.host) @@ -130,6 +145,7 @@ function parseCliOptions(argv: string[]): CliOptions { authUsername: parsed.username, authPassword: parsed.password, generateToken: Boolean(parsed.generateToken), + dangerouslySkipAuth: Boolean(parsed.dangerouslySkipAuth), } } @@ -174,6 +190,12 @@ async function main() { logger.info({ options: logOptions }, "Starting CodeNomad CLI server") + if (options.dangerouslySkipAuth) { + logger.warn( + "DANGEROUS: internal authentication is disabled (--dangerously-skip-auth / CODENOMAD_SKIP_AUTH).", + ) + } + const eventBus = new EventBus(eventLogger) const isLoopbackHost = (host: string) => host === "127.0.0.1" || host === "::1" || host.startsWith("127.") @@ -195,11 +217,12 @@ async function main() { username: options.authUsername, password: options.authPassword, generateToken: options.generateToken, + dangerouslySkipAuth: options.dangerouslySkipAuth, }, logger.child({ component: "auth" }), ) - if (options.generateToken) { + if (options.generateToken && !options.dangerouslySkipAuth) { const token = authManager.issueBootstrapToken() if (token) { console.log(`${BOOTSTRAP_TOKEN_STDOUT_PREFIX}${token}`)