diff --git a/.env.example b/.env.example index 20d9940..f58878d 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,9 @@ # Admin user seeded on first run (only if no users exist in DB) AUTH_USER=admin AUTH_PASS=change-me-on-first-login +# If your password includes "#" and you do not want to quote AUTH_PASS, use base64: +# AUTH_PASS_B64=Y2hhbmdlLW1lLW9uLWZpcnN0LWxvZ2lu +# Example: echo -n 'my#password' | base64 # API key for headless/external access (x-api-key header) API_KEY=generate-a-random-key diff --git a/README.md b/README.md index 1b83318..ca9cec1 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ pnpm dev # http://localhost:3000 ``` Initial login is seeded from `AUTH_USER` / `AUTH_PASS` on first run. +If `AUTH_PASS` contains `#`, quote it (e.g. `AUTH_PASS="my#password"`) or use `AUTH_PASS_B64`. ## Project Status @@ -339,6 +340,7 @@ See [`.env.example`](.env.example) for the complete list. Key variables: |----------|----------|-------------| | `AUTH_USER` | No | Initial admin username (default: `admin`) | | `AUTH_PASS` | No | Initial admin password | +| `AUTH_PASS_B64` | No | Base64-encoded admin password (overrides `AUTH_PASS` if set) | | `API_KEY` | No | API key for headless access | | `OPENCLAW_HOME` | Yes* | Path to `.openclaw` directory | | `OPENCLAW_GATEWAY_HOST` | No | Gateway host (default: `127.0.0.1`) | diff --git a/docs/deployment.md b/docs/deployment.md index ad98b60..feb3a59 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -83,6 +83,7 @@ See `.env.example` for the full list. Key variables: |----------|----------|---------|-------------| | `AUTH_USER` | Yes | `admin` | Admin username (seeded on first run) | | `AUTH_PASS` | Yes | - | Admin password | +| `AUTH_PASS_B64` | No | - | Base64-encoded admin password (overrides `AUTH_PASS` if set) | | `API_KEY` | Yes | - | API key for headless access | | `PORT` | No | `3005` (direct) / `3000` (Docker) | Server port | | `OPENCLAW_HOME` | No | - | Path to OpenClaw installation | @@ -99,6 +100,14 @@ rm -rf node_modules pnpm install ``` +### AUTH_PASS with "#" is not working + +In dotenv files, `#` starts a comment unless the value is quoted. + +Use one of these: +- `AUTH_PASS="my#password"` +- `AUTH_PASS_B64=$(echo -n 'my#password' | base64)` + ### "pnpm-lock.yaml not found" during Docker build If your deployment context omits `pnpm-lock.yaml`, Docker build now falls back to diff --git a/src/lib/__tests__/db-seed-auth-pass.test.ts b/src/lib/__tests__/db-seed-auth-pass.test.ts new file mode 100644 index 0000000..0e6ec77 --- /dev/null +++ b/src/lib/__tests__/db-seed-auth-pass.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' +import { resolveSeedAuthPassword } from '../db' + +describe('resolveSeedAuthPassword', () => { + it('returns AUTH_PASS when AUTH_PASS_B64 is not set', () => { + const password = resolveSeedAuthPassword({ AUTH_PASS: 'plain-secret-123' } as unknown as NodeJS.ProcessEnv) + expect(password).toBe('plain-secret-123') + }) + + it('prefers AUTH_PASS_B64 when present and valid', () => { + const encoded = Buffer.from('secret#with#hash', 'utf8').toString('base64') + const password = resolveSeedAuthPassword({ + AUTH_PASS: 'fallback-value', + AUTH_PASS_B64: encoded, + } as unknown as NodeJS.ProcessEnv) + expect(password).toBe('secret#with#hash') + }) + + it('falls back to AUTH_PASS when AUTH_PASS_B64 is invalid', () => { + const password = resolveSeedAuthPassword({ + AUTH_PASS: 'fallback-value', + AUTH_PASS_B64: '%%%not-base64%%%', + } as unknown as NodeJS.ProcessEnv) + expect(password).toBe('fallback-value') + }) + + it('returns null when no password env var is set', () => { + const password = resolveSeedAuthPassword({} as unknown as NodeJS.ProcessEnv) + expect(password).toBeNull() + }) +}) diff --git a/src/lib/db.ts b/src/lib/db.ts index 76d5cc7..931ab43 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -83,6 +83,33 @@ const INSECURE_PASSWORDS = new Set([ 'testpass123', ]) +export function resolveSeedAuthPassword(env: NodeJS.ProcessEnv = process.env): string | null { + const b64 = env.AUTH_PASS_B64 + if (b64 && b64.trim().length > 0) { + const normalized = b64.trim() + const base64Pattern = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/ + if (!base64Pattern.test(normalized)) { + logger.warn('AUTH_PASS_B64 is not valid base64; falling back to AUTH_PASS') + return env.AUTH_PASS || null + } + + try { + const decoded = Buffer.from(normalized, 'base64').toString('utf8') + const canonical = Buffer.from(decoded, 'utf8').toString('base64') + if (canonical !== normalized) { + logger.warn('AUTH_PASS_B64 failed base64 verification; falling back to AUTH_PASS') + return env.AUTH_PASS || null + } + if (decoded.length > 0) return decoded + logger.warn('AUTH_PASS_B64 is set but decoded to an empty value; falling back to AUTH_PASS') + } catch { + logger.warn('AUTH_PASS_B64 is not valid base64; falling back to AUTH_PASS') + } + } + + return env.AUTH_PASS || null +} + function seedAdminUserFromEnv(dbConn: Database.Database): void { // Skip seeding during `next build` — env vars may not be available yet if (process.env.NEXT_PHASE === 'phase-production-build') return @@ -91,12 +118,12 @@ function seedAdminUserFromEnv(dbConn: Database.Database): void { if (count > 0) return const username = process.env.AUTH_USER || 'admin' - const password = process.env.AUTH_PASS + const password = resolveSeedAuthPassword() if (!password) { logger.warn( 'AUTH_PASS is not set — skipping admin user seeding. ' + - 'Set AUTH_PASS in your .env file to create the initial admin account.' + 'Set AUTH_PASS (quote values containing #) or AUTH_PASS_B64 in your environment.' ) return }