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:
@@ -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.)
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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}`)
|
||||||
|
|||||||
Reference in New Issue
Block a user