feat(server): allow skipping internal auth

Add --dangerously-skip-auth / CODENOMAD_SKIP_AUTH for trusted-perimeter deployments so users behind SSO/VPN don't need a second login.
This commit is contained in:
Shantur Rathore
2026-01-29 20:38:05 +00:00
parent 37b7c1e53c
commit afa7243eab
3 changed files with 77 additions and 6 deletions

View File

@@ -51,8 +51,17 @@ You can configure the server using flags or environment variables:
| `--config <path>` | `CLI_CONFIG` | Config file location | | `--config <path>` | `CLI_CONFIG` | Config file location |
| `--launch` | `CLI_LAUNCH` | Open the UI in a Chromium-based browser | | `--launch` | `CLI_LAUNCH` | Open the UI in a Chromium-based browser |
| `--log-level <level>` | `CLI_LOG_LEVEL` | Logging level (trace, debug, info, warn, error) | | `--log-level <level>` | `CLI_LOG_LEVEL` | Logging level (trace, debug, info, warn, error) |
| `--username <username>` | `CODENOMAD_SERVER_USERNAME` | Username for CodeNomad's internal auth (default `codenomad`) |
| `--password <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 ### Data Storage
- **Config**: `~/.config/codenomad/config.json` - **Config**: `~/.config/codenomad/config.json`
- **Instance Data**: `~/.config/codenomad/instances` (chat history, etc.) - **Instance Data**: `~/.config/codenomad/instances` (chat history, etc.)

View File

@@ -15,15 +15,25 @@ export interface AuthManagerInit {
username: string username: string
password?: string password?: string
generateToken: boolean generateToken: boolean
dangerouslySkipAuth?: boolean
} }
export class AuthManager { export class AuthManager {
private readonly authStore: AuthStore private readonly authStore: AuthStore | null
private readonly tokenManager: TokenManager | null private readonly tokenManager: TokenManager | null
private readonly sessionManager = new SessionManager() private readonly sessionManager = new SessionManager()
private readonly cookieName = DEFAULT_AUTH_COOKIE_NAME private readonly cookieName = DEFAULT_AUTH_COOKIE_NAME
private readonly authEnabled: boolean
constructor(private readonly init: AuthManagerInit, private readonly logger: Logger) { 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) const authFilePath = resolveAuthFilePath(init.configPath)
this.authStore = new AuthStore(authFilePath, logger.child({ component: "auth" })) 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 this.tokenManager = init.generateToken ? new TokenManager(60_000) : null
} }
isAuthEnabled(): boolean {
return this.authEnabled
}
getCookieName(): string { getCookieName(): string {
return this.cookieName return this.cookieName
} }
@@ -56,19 +70,31 @@ export class AuthManager {
} }
validateLogin(username: string, password: string): boolean { 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) { createSession(username: string) {
if (!this.authEnabled) {
return { id: "auth-disabled", createdAt: Date.now(), username: this.init.username }
}
return this.sessionManager.createSession(username) return this.sessionManager.createSession(username)
} }
getStatus() { getStatus() {
return this.authStore.getStatus() if (!this.authEnabled) {
return { username: this.init.username, passwordUserProvided: false }
}
return this.requireAuthStore().getStatus()
} }
setPassword(password: string) { 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 { isLoopbackRequest(request: FastifyRequest): boolean {
@@ -76,6 +102,12 @@ export class AuthManager {
} }
getSessionFromRequest(request: FastifyRequest): { username: string; sessionId: string } | null { 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 cookies = parseCookies(request.headers.cookie)
const sessionId = cookies[this.cookieName] const sessionId = cookies[this.cookieName]
const session = this.sessionManager.getSession(sessionId) const session = this.sessionManager.getSession(sessionId)
@@ -90,6 +122,13 @@ export class AuthManager {
clearSessionCookie(reply: FastifyReply) { clearSessionCookie(reply: FastifyReply) {
reply.header("Set-Cookie", buildSessionCookie(this.cookieName, "", { maxAgeSeconds: 0 })) 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) { function resolveAuthFilePath(configPath: string) {

View File

@@ -44,6 +44,7 @@ interface CliOptions {
authUsername: string authUsername: string
authPassword?: string authPassword?: string
generateToken: boolean generateToken: boolean
dangerouslySkipAuth: boolean
} }
const DEFAULT_PORT = 9898 const DEFAULT_PORT = 9898
@@ -84,6 +85,14 @@ function parseCliOptions(argv: string[]): CliOptions {
.env("CODENOMAD_GENERATE_TOKEN") .env("CODENOMAD_GENERATE_TOKEN")
.default(false), .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" }) program.parse(argv, { from: "user" })
const parsed = program.opts<{ const parsed = program.opts<{
@@ -104,8 +113,14 @@ function parseCliOptions(argv: string[]): CliOptions {
username: string username: string
password?: string password?: string
generateToken?: boolean 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 resolvedRoot = parsed.workspaceRoot ?? parsed.root ?? process.cwd()
const normalizedHost = resolveHost(parsed.host) const normalizedHost = resolveHost(parsed.host)
@@ -130,6 +145,7 @@ function parseCliOptions(argv: string[]): CliOptions {
authUsername: parsed.username, authUsername: parsed.username,
authPassword: parsed.password, authPassword: parsed.password,
generateToken: Boolean(parsed.generateToken), generateToken: Boolean(parsed.generateToken),
dangerouslySkipAuth: Boolean(parsed.dangerouslySkipAuth),
} }
} }
@@ -174,6 +190,12 @@ async function main() {
logger.info({ options: logOptions }, "Starting CodeNomad CLI server") 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 eventBus = new EventBus(eventLogger)
const isLoopbackHost = (host: string) => host === "127.0.0.1" || host === "::1" || host.startsWith("127.") const isLoopbackHost = (host: string) => host === "127.0.0.1" || host === "::1" || host.startsWith("127.")
@@ -195,11 +217,12 @@ async function main() {
username: options.authUsername, username: options.authUsername,
password: options.authPassword, password: options.authPassword,
generateToken: options.generateToken, generateToken: options.generateToken,
dangerouslySkipAuth: options.dangerouslySkipAuth,
}, },
logger.child({ component: "auth" }), logger.child({ component: "auth" }),
) )
if (options.generateToken) { if (options.generateToken && !options.dangerouslySkipAuth) {
const token = authManager.issueBootstrapToken() const token = authManager.issueBootstrapToken()
if (token) { if (token) {
console.log(`${BOOTSTRAP_TOKEN_STDOUT_PREFIX}${token}`) console.log(`${BOOTSTRAP_TOKEN_STDOUT_PREFIX}${token}`)